diff --git a/.docker/caddy/Caddyfile b/.docker/caddy/Caddyfile
index c068e857c..cf2c5365d 100644
--- a/.docker/caddy/Caddyfile
+++ b/.docker/caddy/Caddyfile
@@ -35,7 +35,7 @@
handle @api {
uri strip_prefix /_roomote-api
- reverse_proxy host.docker.internal:13101 {
+ reverse_proxy host.docker.internal:13001 {
lb_try_duration 10s
lb_try_interval 250ms
}
@@ -58,7 +58,7 @@
}
handle {
- reverse_proxy host.docker.internal:13100 {
+ reverse_proxy host.docker.internal:13000 {
lb_try_duration 10s
lb_try_interval 250ms
}
diff --git a/apps/web/.gitignore b/apps/web/.gitignore
index c3d8993af..8a52c871c 100644
--- a/apps/web/.gitignore
+++ b/apps/web/.gitignore
@@ -2,6 +2,7 @@
/.next
/.source
/out
+/public/docs
next-env.d.ts
*storybook.log
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index 914de3be2..b56c1c2f1 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -37,7 +37,7 @@ const nextConfig: NextConfig = {
// Always bundle the env files the runtime may need so preview deploys can
// load preview secrets even when build-time env detection resolves differently.
outputFileTracingIncludes: {
- '/*': webEnvFiles,
+ '/*': [...webEnvFiles, '../docs/**/*'],
},
experimental: {
serverActions: {
@@ -68,9 +68,6 @@ const nextConfig: NextConfig = {
// (e.g. "taskid-web.preview-john.ngrok.app"), so we need *.domain entries.
allowedDevOrigins: getAllowedDevOrigins(process.env.PREVIEW_DOMAINS),
async redirects() {
- // Public product docs now live at the standalone Mintlify site
- // (https://docs.roomote.dev). Preserve old in-app /docs URLs by
- // redirecting them to the external docs site.
return [
{
source: '/docs',
diff --git a/apps/web/package.json b/apps/web/package.json
index cb31e6684..769d11f91 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -9,10 +9,11 @@
"check-types": "NODE_OPTIONS='--max-old-space-size=4096' tsc --noEmit",
"check-types:fast": "tsgo --noEmit",
"test": "dotenvx run -f ../../.env.test -- vitest",
- "dev:prepare": "rimraf .next",
- "dev": "dotenvx run -f ../../.env.local -- next dev --turbopack",
- "build": "NODE_OPTIONS='--max-old-space-size=4096' node ./scripts/run-with-app-env.mjs --default-env production -- next build",
- "build:debug": "NODE_OPTIONS='--max-old-space-size=4096' node ./scripts/run-with-app-env.mjs --default-env production -- next build --experimental-debug-memory-usage",
+ "docs:assets": "node ./scripts/sync-docs-assets.mjs",
+ "dev:prepare": "rimraf .next && pnpm docs:assets",
+ "dev": "pnpm docs:assets && dotenvx run -f ../../.env.local -- next dev --turbopack",
+ "build": "pnpm docs:assets && NODE_OPTIONS='--max-old-space-size=4096' node ./scripts/run-with-app-env.mjs --default-env production -- next build",
+ "build:debug": "pnpm docs:assets && NODE_OPTIONS='--max-old-space-size=4096' node ./scripts/run-with-app-env.mjs --default-env production -- next build --experimental-debug-memory-usage",
"start": "node ./scripts/run-with-app-env.mjs --default-env production -- next start",
"clean": "rimraf .next .turbo out",
"trigger-task-suggestions": "tsx scripts/trigger-task-suggestions.ts",
@@ -86,10 +87,12 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
+ "gray-matter": "^4.0.3",
"lucide-react": "^0.575.0",
"modal": "^0.7.6",
"motion": "^12.29.2",
"next": "^16.2.11",
+ "next-mdx-remote": "^6.0.0",
"next-themes": "^0.4.6",
"pino": "^9.7.0",
"pino-pretty": "^13.1.3",
@@ -100,6 +103,7 @@
"react-resizable-panels": "^2.1.7",
"recharts": "^3.8.0",
"remark-breaks": "^4.0.0",
+ "remark-gfm": "^4.0.1",
"remove-markdown": "^0.6.3",
"shiki": "^3.19.0",
"simple-icons": "16.12.0",
diff --git a/apps/web/scripts/sync-docs-assets.mjs b/apps/web/scripts/sync-docs-assets.mjs
new file mode 100644
index 000000000..f07f9f74f
--- /dev/null
+++ b/apps/web/scripts/sync-docs-assets.mjs
@@ -0,0 +1,9 @@
+import { cpSync, existsSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+const source = resolve(import.meta.dirname, '../../docs/logo');
+const destination = resolve(import.meta.dirname, '../public/docs/logo');
+
+if (existsSync(source)) {
+ cpSync(source, destination, { recursive: true });
+}
diff --git a/apps/web/src/app/(onboarding)/setup/DocsMdx.client.test.tsx b/apps/web/src/app/(onboarding)/setup/DocsMdx.client.test.tsx
new file mode 100644
index 000000000..e62395f08
--- /dev/null
+++ b/apps/web/src/app/(onboarding)/setup/DocsMdx.client.test.tsx
@@ -0,0 +1,23 @@
+import { render, screen } from '@testing-library/react';
+
+import { docsMdxComponents } from './DocsMdx';
+
+describe('docsMdxComponents', () => {
+ it('opens documentation links in a new tab', () => {
+ const DocsLink = docsMdxComponents.a;
+
+ render(
+
+ Self-hosting
+ ,
+ );
+
+ const link = screen.getByRole('link', { name: 'Self-hosting' });
+ expect(link).toHaveAttribute(
+ 'href',
+ 'https://docs.roomote.dev/self-hosting',
+ );
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+ });
+});
diff --git a/apps/web/src/app/(onboarding)/setup/DocsMdx.tsx b/apps/web/src/app/(onboarding)/setup/DocsMdx.tsx
new file mode 100644
index 000000000..3d90f3c29
--- /dev/null
+++ b/apps/web/src/app/(onboarding)/setup/DocsMdx.tsx
@@ -0,0 +1,95 @@
+import type { ComponentProps, ReactNode } from 'react';
+import {
+ Alert,
+ AlertDescription,
+ AlertTriangle,
+ Info,
+} from '@/components/system';
+import { DOCS_BASE_URL } from '@/lib/docs';
+
+function DocsLink({ href = '', ...props }: ComponentProps<'a'>) {
+ const resolvedHref = href.startsWith('/') ? `${DOCS_BASE_URL}${href}` : href;
+
+ return (
+
+ );
+}
+
+function Warning({ children }: { children: ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
+ );
+}
+
+function Tip({ children }: { children: ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
+ );
+}
+
+function Steps({ children }: { children: ReactNode }) {
+ return
{children}
;
+}
+
+function Step({ title, children }: { title: string; children: ReactNode }) {
+ return (
+
+ {title}
+ {children}
+
+ );
+}
+
+function IntegrationName({
+ href,
+ icon,
+ name,
+}: {
+ href: string;
+ icon: string;
+ name: string;
+}) {
+ const manualIcons: Record = {
+ daytona: '/docs/logo/integrations/daytona.svg',
+ e2b: '/docs/logo/integrations/e2b.svg',
+ blaxel: '/docs/logo/integrations/blaxel.svg',
+ };
+ const iconSrc =
+ manualIcons[icon] ??
+ (icon.startsWith('/')
+ ? `/docs${icon}`
+ : `https://api.iconify.design/simple-icons:${icon}.svg?color=currentColor`);
+
+ return (
+
+ {/* This mirrors the Mintlify snippet and external icons do not need Next optimization. */}
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+ {name}
+
+ );
+}
+
+export const docsMdxComponents = {
+ a: DocsLink,
+ Warning,
+ Tip,
+ Steps,
+ Step,
+ IntegrationName,
+};
diff --git a/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx b/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx
new file mode 100644
index 000000000..878a85ca7
--- /dev/null
+++ b/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx
@@ -0,0 +1,68 @@
+import { useState } from 'react';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { vi } from 'vitest';
+
+vi.mock('next/navigation', () => ({
+ useSearchParams: () => new URLSearchParams('step=auth-provider'),
+}));
+
+import { SetupDocs } from './SetupDocs';
+import { getSetupDocsPath, getSetupDocsStep } from './setup-docs';
+
+describe('SetupDocs', () => {
+ it('maps setup steps to the matching documentation pages', () => {
+ expect(getSetupDocsPath('auth-provider')).toBe('communications');
+ expect(
+ getSetupDocsPath('auth-env-vars', { authProvider: 'microsoft' }),
+ ).toBe('providers/communications/microsoft-teams');
+ expect(getSetupDocsPath('slack', { authProvider: 'microsoft' })).toBe(
+ 'providers/communications/microsoft-teams',
+ );
+ expect(
+ getSetupDocsPath('source-control-connect', {
+ sourceControlProvider: 'github',
+ }),
+ ).toBe('providers/source-control/github');
+ expect(getSetupDocsPath('compute-config', { computeProvider: 'e2b' })).toBe(
+ 'providers/compute/e2b',
+ );
+ expect(getSetupDocsPath('env-vars', { modelProvider: 'vllm' })).toBe(
+ 'providers/inference/vllm',
+ );
+ expect(getSetupDocsPath('repo-selection')).toBe('environments');
+ expect(getSetupDocsPath('welcome')).toBeNull();
+ expect(getSetupDocsStep('email-account')).toBe('email-account');
+ expect(getSetupDocsStep(null)).toBe('welcome');
+ });
+
+ it('opens and closes the desktop documentation frame', () => {
+ function SetupDocsHarness() {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+
+ Docs content
+
+ );
+ }
+
+ render();
+
+ expect(screen.queryByText('Docs content')).not.toBeInTheDocument();
+
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Need help? Docs are here' }),
+ );
+
+ expect(screen.getByText('Docs content')).toBeInTheDocument();
+ expect(
+ screen.getByRole('link', {
+ name: 'Open this documentation page in a new tab',
+ }),
+ ).toHaveAttribute('href', 'https://docs.roomote.dev/communications');
+
+ fireEvent.click(screen.getByRole('button', { name: 'Close docs' }));
+
+ expect(screen.queryByText('Docs content')).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/app/(onboarding)/setup/SetupDocs.tsx b/apps/web/src/app/(onboarding)/setup/SetupDocs.tsx
new file mode 100644
index 000000000..9be014a98
--- /dev/null
+++ b/apps/web/src/app/(onboarding)/setup/SetupDocs.tsx
@@ -0,0 +1,87 @@
+'use client';
+
+import type { ReactNode } from 'react';
+import { useSearchParams } from 'next/navigation';
+import { Button, ExternalLink } from '@/components/system';
+
+import { getSetupDocsPath, getSetupDocsStep } from './setup-docs';
+import { ArrowLeftToLine, ArrowRightToLine } from 'lucide-react';
+
+export function SetupDocs({
+ isOpen,
+ onOpenChange,
+ children,
+}: {
+ isOpen: boolean;
+ onOpenChange: (isOpen: boolean) => void;
+ children: ReactNode;
+}) {
+ const searchParams = useSearchParams();
+ const docsPath = getSetupDocsPath(
+ getSetupDocsStep(searchParams?.get('step') ?? null),
+ {
+ authProvider: searchParams?.get('authProvider'),
+ computeProvider: searchParams?.get('computeProvider'),
+ modelProvider: searchParams?.get('modelProvider'),
+ sourceControlProvider: searchParams?.get('sourceControlProvider'),
+ },
+ );
+
+ return (
+ <>
+
+ {isOpen ? (
+
+
+
Setup docs
+
+
+
+
+
+
+ {children}
+
+
+ ) : null}
+
+
+
+
+ >
+ );
+}
diff --git a/apps/web/src/app/(onboarding)/setup/SetupDocsContext.tsx b/apps/web/src/app/(onboarding)/setup/SetupDocsContext.tsx
new file mode 100644
index 000000000..89b43db06
--- /dev/null
+++ b/apps/web/src/app/(onboarding)/setup/SetupDocsContext.tsx
@@ -0,0 +1,24 @@
+'use client';
+
+import { createContext, useContext } from 'react';
+import type { ReactNode } from 'react';
+
+const SetupDocsContext = createContext<(content: ReactNode) => void>(() => {});
+
+export function SetupDocsContentProvider({
+ children,
+ setContent,
+}: {
+ children: ReactNode;
+ setContent: (content: ReactNode) => void;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function useSetSetupDocsContent() {
+ return useContext(SetupDocsContext);
+}
diff --git a/apps/web/src/app/(onboarding)/setup/SetupLayoutClient.tsx b/apps/web/src/app/(onboarding)/setup/SetupLayoutClient.tsx
index e9be4d671..d1d42f39b 100644
--- a/apps/web/src/app/(onboarding)/setup/SetupLayoutClient.tsx
+++ b/apps/web/src/app/(onboarding)/setup/SetupLayoutClient.tsx
@@ -1,6 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
+import type { ReactNode } from 'react';
import { useRouter } from 'next/navigation';
import { useSetupBootstrapOpen, useUser } from '@/hooks/useUser';
@@ -11,6 +12,12 @@ import {
UserMenu,
} from '@/components/layout';
import { Spinner } from '@/components/system';
+import { cn } from '@/lib/utils';
+
+import { SetupDocs } from './SetupDocs';
+import { SetupDocsContentProvider } from './SetupDocsContext';
+
+const SETUP_DOCS_OPEN_STORAGE_KEY = 'roomote:setup-docs-open';
export function SetupLayoutClient({ children }: { children: React.ReactNode }) {
const router = useRouter();
@@ -18,6 +25,29 @@ export function SetupLayoutClient({ children }: { children: React.ReactNode }) {
const setupBootstrapOpen = useSetupBootstrapOpen();
const [userMenuPortalContainer, setUserMenuPortalContainer] =
useState(null);
+ const [isDocsOpen, setIsDocsOpen] = useState(false);
+ const [docsContent, setDocsContent] = useState(null);
+ const hasDocsContent = docsContent !== null;
+
+ useEffect(() => {
+ try {
+ setIsDocsOpen(
+ window.localStorage.getItem(SETUP_DOCS_OPEN_STORAGE_KEY) === 'true',
+ );
+ } catch {
+ // Ignore localStorage failures.
+ }
+ }, []);
+
+ const handleDocsOpenChange = (isOpen: boolean) => {
+ setIsDocsOpen(isOpen);
+
+ try {
+ window.localStorage.setItem(SETUP_DOCS_OPEN_STORAGE_KEY, String(isOpen));
+ } catch {
+ // Ignore localStorage failures.
+ }
+ };
useEffect(() => {
if (authStatus === 'signed-out' && !setupBootstrapOpen) {
@@ -34,31 +64,51 @@ export function SetupLayoutClient({ children }: { children: React.ReactNode }) {
}
return (
-
-
-
-
- {isSignedIn ? (
- <>
-
-
-
-
- >
- ) : null}
+
+
+
+
+
+ {hasDocsContent ? (
+
+ {docsContent}
+
+ ) : null}
+ {isSignedIn ? (
+ <>
+
+
+
+
+ >
+ ) : null}
-
+
+
+
);
}
diff --git a/apps/web/src/app/(onboarding)/setup/SetupPageClient.tsx b/apps/web/src/app/(onboarding)/setup/SetupPageClient.tsx
new file mode 100644
index 000000000..6f37c62c6
--- /dev/null
+++ b/apps/web/src/app/(onboarding)/setup/SetupPageClient.tsx
@@ -0,0 +1,851 @@
+'use client';
+
+import { AnimatePresence, motion } from 'motion/react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import type { ReactNode } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useRouter } from 'next/navigation';
+import { toast } from 'sonner';
+import {
+ getSetupNewComputeProvisioningState,
+ isComputeCredentialField,
+ isSetupProvisionableComputeProvider,
+ type ComputeProvider,
+ type SetupModelProviderId,
+ type SourceControlProvider,
+} from '@roomote/types';
+
+import { useRedirectToSignIn } from '@/hooks/useSignInRedirect';
+import { useSetupBootstrapOpen, useUser } from '@/hooks/useUser';
+import {
+ INVITE_COOKIE_NAME,
+ readInviteTokenFromDocumentCookie,
+} from '@/lib/invite-cookie';
+import {
+ DEFAULT_SETUP_REDIRECT_PATH,
+ getSetupRedirectPath,
+} from '@/lib/setup-status';
+import { useTRPC } from '@/trpc/client';
+import { Button, Spinner } from '@/components/system';
+
+import { StepWelcome } from './StepWelcome';
+import { StepAuthEnvVars } from './StepAuthEnvVars';
+import { StepBootstrapAccount } from './StepBootstrapAccount';
+import { StepBootstrapEmailPassword } from './StepBootstrapEmailPassword';
+import { StepSetupToken } from './StepSetupToken';
+import {
+ StepAuthProvider,
+ type CommunicationProviderChoice,
+} from './StepAuthProvider';
+import { StepTelegramSetup } from './StepTelegramSetup';
+import { StepDiscordSetup } from './StepDiscordSetup';
+import { StepInferenceProvider } from './StepInferenceProvider';
+import { StepComputeProvider } from './StepComputeProvider';
+import { StepComputeConfig } from './StepComputeConfig';
+import { StepSourceControlProvider } from './StepSourceControlProvider';
+import { StepSourceControlConfig } from './StepSourceControlConfig';
+import { StepSourceControlConnect } from './StepSourceControlConnect';
+import { StepCommunicationConnect } from './StepCommunicationConnect';
+import { StepInvoke } from './StepInvoke';
+import { useSetupFlow } from './hooks';
+import { StepRepoSelection, type SetupRetryReason } from './StepRepoSelection';
+import { getSetupStepPath } from './types';
+import { useSetSetupDocsContent } from './SetupDocsContext';
+import {
+ getBootstrapAuthProvider,
+ getBootstrapStepPath,
+ getBootstrapStepFromSetupStepParam,
+ getBootstrapStepAfterWelcome,
+ getNextBootstrapStep,
+ type BootstrapStep,
+} from './bootstrapFlow';
+
+function getSetupRetryReason(status: {
+ onboardingFailed: boolean;
+ onboardingTaskStatus: string | null;
+ onboardingTaskPhase?: string | null;
+ matchingEnvironment: { id: string; name: string } | null;
+}): SetupRetryReason | null {
+ if (!status.onboardingFailed) {
+ return null;
+ }
+
+ if (
+ (status.onboardingTaskStatus === 'completed' ||
+ (status.onboardingTaskStatus === 'idle' &&
+ status.onboardingTaskPhase === 'waiting_for_prompt')) &&
+ status.matchingEnvironment === null
+ ) {
+ return 'no-environment';
+ }
+
+ if (status.onboardingTaskStatus === 'canceled') {
+ return 'task-canceled';
+ }
+
+ return 'task-failed';
+}
+
+function getInitialBootstrapStep(): BootstrapStep {
+ if (typeof window === 'undefined') {
+ return 'welcome';
+ }
+
+ return (
+ getBootstrapStepFromSetupStepParam(
+ new URLSearchParams(window.location.search).get('step'),
+ ) ?? 'welcome'
+ );
+}
+
+const BOOTSTRAP_STEPS: readonly BootstrapStep[] = [
+ 'welcome',
+ 'email-account',
+ 'email-password',
+ 'auth-provider',
+ 'auth-env-vars',
+];
+
+export default function SetupPageClient({
+ setupDocsContent,
+}: {
+ setupDocsContent: ReactNode;
+}) {
+ const router = useRouter();
+ const setSetupDocsContent = useSetSetupDocsContent();
+ const setupBootstrapOpen = useSetupBootstrapOpen();
+ const { authStatus, isSignedIn, user } = useUser();
+ const isAdmin = user?.isAdmin === true;
+
+ useEffect(() => {
+ setSetupDocsContent(setupDocsContent);
+ }, [setSetupDocsContent, setupDocsContent]);
+ const shouldRedirectToSignIn =
+ authStatus === 'signed-out' && !setupBootstrapOpen;
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const [bootstrapStep, setBootstrapStep] = useState(
+ getInitialBootstrapStep,
+ );
+ const [bootstrapTransitionDirection, setBootstrapTransitionDirection] =
+ useState<'forward' | 'backward'>('forward');
+ const bootstrapStepRef = useRef(bootstrapStep);
+ bootstrapStepRef.current = bootstrapStep;
+ const setBootstrapStepWithTransition = useCallback(
+ (nextStep: BootstrapStep) => {
+ const currentIndex = BOOTSTRAP_STEPS.indexOf(bootstrapStepRef.current);
+ const nextIndex = BOOTSTRAP_STEPS.indexOf(nextStep);
+
+ if (nextStep !== bootstrapStepRef.current) {
+ setBootstrapTransitionDirection(
+ nextIndex >= currentIndex ? 'forward' : 'backward',
+ );
+ }
+
+ bootstrapStepRef.current = nextStep;
+ setBootstrapStep(nextStep);
+ router.replace(
+ getBootstrapStepPath(
+ nextStep,
+ new URLSearchParams(window.location.search),
+ ),
+ );
+ },
+ [router],
+ );
+ const [pendingAuthProvider, setPendingAuthProvider] =
+ useState(null);
+ const pendingSetupAuthProvider =
+ pendingAuthProvider === 'telegram' || pendingAuthProvider === 'discord'
+ ? null
+ : pendingAuthProvider;
+ const [pendingSourceControlProvider, setPendingSourceControlProvider] =
+ useState(null);
+ const [pendingComputeProvider, setPendingComputeProvider] =
+ useState(null);
+ const [pendingModelProvider, setPendingModelProvider] =
+ useState(null);
+ const [setupToken, setSetupToken] = useState(() => {
+ if (typeof window === 'undefined') {
+ return null;
+ }
+
+ // OAuth sign-in round-trips return to /setup without the ?token= query
+ // param, so fall back to the invite cookie to avoid bouncing the visitor
+ // back to the setup-token gate mid-flow.
+ return (
+ new URLSearchParams(window.location.search).get('token') ??
+ readInviteTokenFromDocumentCookie()
+ );
+ });
+ const { data: bootstrapStatus, isLoading: isBootstrapLoading } = useQuery(
+ trpc.setupBootstrap.status.queryOptions(
+ setupToken ? { setupToken } : undefined,
+ {
+ enabled: !isSignedIn && setupBootstrapOpen,
+ staleTime: 5_000,
+ },
+ ),
+ );
+ const {
+ data: setupStatus,
+ isLoading: isSetupStatusLoading,
+ isError: isSetupStatusError,
+ } = useQuery(
+ trpc.setup.status.queryOptions(undefined, {
+ enabled: isSignedIn && isAdmin,
+ staleTime: 30_000,
+ }),
+ );
+ const {
+ step,
+ transitionDirection,
+ entryContext,
+ goToStep,
+ goToPreviousStep,
+ goToNextStep,
+ canGoBack,
+ goToNextPostOnboardingStep,
+ status,
+ setupSession,
+ isLoading,
+ isError,
+ } = useSetupFlow({
+ enabled: isSignedIn && isAdmin,
+ pendingAuthProvider: pendingSetupAuthProvider,
+ });
+ const saveSourceControlProviderChoice = useMutation(
+ trpc.setupNew.saveSourceControlProviderChoice.mutationOptions({
+ onSuccess: async (_data, variables) => {
+ await queryClient.invalidateQueries({
+ queryKey: trpc.setupNew.status.queryKey(),
+ });
+ setPendingSourceControlProvider(variables.provider);
+ goToStep('source-control-config');
+ },
+ onError: (error) => {
+ toast.error(error.message);
+ },
+ }),
+ );
+ const saveAuthProviderChoice = useMutation(
+ trpc.setupNew.saveAuthProviderChoice.mutationOptions({
+ onSuccess: async (_data, variables) => {
+ await queryClient.invalidateQueries({
+ queryKey: trpc.setupNew.status.queryKey(),
+ });
+ // Only expose the choice as pending once the save has succeeded, so a
+ // failed request can never prematurely skip the chooser with an
+ // unsaved selection.
+ setPendingAuthProvider(variables.provider);
+ goToStep('auth-env-vars');
+ },
+ onError: (error) => {
+ toast.error(error.message);
+ },
+ }),
+ );
+ const saveComputeProviderChoice = useMutation(
+ trpc.setupNew.saveComputeProviderChoice.mutationOptions({
+ onSuccess: async (_data, variables) => {
+ await queryClient.invalidateQueries({
+ queryKey: trpc.setupNew.status.queryKey(),
+ });
+ setPendingComputeProvider(variables.provider);
+ const selectedProvider = status?.computeSetup.providers.find(
+ (provider) => provider.provider === variables.provider,
+ );
+
+ // Credentialless providers stay on the short path even when they
+ // expose optional advanced settings later in Settings.
+ if (
+ selectedProvider &&
+ !selectedProvider.fields.some(isComputeCredentialField)
+ ) {
+ goToNextStep();
+ return;
+ }
+
+ // Pin the config step so already-satisfied credentials stay
+ // reviewable/editable instead of auto-skipping past them.
+ goToStep('compute-config', { revisit: true });
+ },
+ onError: (error) => {
+ toast.error(error.message);
+ },
+ }),
+ );
+ const hasPersistedSelectedSuggestedTasks = (
+ status?.queuedOnboardingTasks ?? []
+ ).some((task) => task.suggestionId !== null);
+ const bootstrapAuthProvider = getBootstrapAuthProvider(
+ bootstrapStatus?.authSetup,
+ pendingSetupAuthProvider,
+ );
+ const setupRetryReason = status ? getSetupRetryReason(status) : null;
+ const selectedComputeProvider =
+ pendingComputeProvider ?? status?.setupNewState.computeProvider;
+ const selectedAuthProvider =
+ pendingAuthProvider ??
+ status?.setupNewState.authProvider ??
+ status?.authSetup.runtimeConfiguredProvider ??
+ status?.authSetup.selectedProvider;
+ const selectedSourceControlProvider =
+ pendingSourceControlProvider ??
+ status?.setupNewState.sourceControlProvider ??
+ status?.sourceControlSetup.runtimeConfiguredProvider ??
+ status?.sourceControlSetup.selectedProvider;
+ const selectedModelProvider =
+ pendingModelProvider ?? status?.setupNewState.modelProvider;
+
+ useEffect(() => {
+ const params = new URLSearchParams(window.location.search);
+ const providerParams = {
+ authProvider: selectedAuthProvider,
+ computeProvider: selectedComputeProvider,
+ modelProvider: selectedModelProvider,
+ sourceControlProvider: selectedSourceControlProvider,
+ };
+ let changed = false;
+
+ for (const [key, value] of Object.entries(providerParams)) {
+ if (value && params.get(key) !== value) {
+ params.set(key, value);
+ changed = true;
+ } else if (!value && params.has(key)) {
+ params.delete(key);
+ changed = true;
+ }
+ }
+
+ if (changed) {
+ router.replace(`${window.location.pathname}?${params}`);
+ }
+ }, [
+ router,
+ selectedAuthProvider,
+ selectedComputeProvider,
+ selectedModelProvider,
+ selectedSourceControlProvider,
+ ]);
+ const computeProvisioning =
+ status &&
+ selectedComputeProvider &&
+ isSetupProvisionableComputeProvider(selectedComputeProvider)
+ ? getSetupNewComputeProvisioningState(
+ status.setupNewState,
+ selectedComputeProvider,
+ )
+ : null;
+ const shouldEvaluateSetupRedirect = isSignedIn && isAdmin;
+ const setupRedirectPath =
+ shouldEvaluateSetupRedirect && !isSetupStatusError && setupStatus != null
+ ? getSetupRedirectPath(setupStatus)
+ : null;
+ // When the user finishes setup from the invoke step, setup.status flips to
+ // completed before StepInvoke navigates to the onboarding task. Without this
+ // guard the effect below would replace('/') first and flash Home.
+ const observedIncompleteSetupRef = useRef(false);
+ useRedirectToSignIn(shouldRedirectToSignIn);
+
+ useEffect(() => {
+ if (setupStatus != null && setupStatus.setupCompletedAt == null) {
+ observedIncompleteSetupRef.current = true;
+ }
+ }, [setupStatus]);
+
+ useEffect(() => {
+ if (authStatus === 'signed-in' && !isAdmin) {
+ router.replace('/');
+ return;
+ }
+
+ if (!shouldEvaluateSetupRedirect) {
+ return;
+ }
+
+ if (!isSetupStatusLoading && !isSetupStatusError && setupStatus != null) {
+ if (
+ setupRedirectPath &&
+ setupRedirectPath !== DEFAULT_SETUP_REDIRECT_PATH
+ ) {
+ router.replace(setupRedirectPath);
+ return;
+ }
+
+ if (setupRedirectPath === null && !observedIncompleteSetupRef.current) {
+ router.replace('/');
+ return;
+ }
+ }
+
+ if (isError) {
+ router.replace('/');
+ }
+ }, [
+ authStatus,
+ isAdmin,
+ isError,
+ isSetupStatusError,
+ isSetupStatusLoading,
+ shouldEvaluateSetupRedirect,
+ router,
+ setupStatus,
+ setupRedirectPath,
+ ]);
+
+ useEffect(() => {
+ if (isSignedIn || !bootstrapStatus) {
+ return;
+ }
+
+ if (!bootstrapStatus.setupOpen) {
+ router.replace('/sign-in');
+ return;
+ }
+
+ const currentStep = bootstrapStepRef.current;
+ let nextStep = currentStep;
+
+ if (currentStep !== 'welcome') {
+ if (currentStep === 'email-account' || currentStep === 'email-password') {
+ const nextBootstrapStep = getBootstrapStepAfterWelcome(
+ bootstrapStatus.authSetup,
+ );
+ nextStep =
+ nextBootstrapStep === 'email-account'
+ ? currentStep
+ : nextBootstrapStep;
+ } else {
+ nextStep = getNextBootstrapStep(
+ bootstrapStatus.authSetup,
+ pendingSetupAuthProvider,
+ );
+ }
+ }
+
+ if (nextStep !== currentStep) {
+ setBootstrapStepWithTransition(nextStep);
+ }
+ }, [
+ bootstrapStatus,
+ isSignedIn,
+ pendingSetupAuthProvider,
+ router,
+ setBootstrapStepWithTransition,
+ ]);
+
+ useEffect(() => {
+ if (status?.setupNewState.authProvider) {
+ setPendingAuthProvider(null);
+ }
+ }, [status?.setupNewState.authProvider]);
+
+ useEffect(() => {
+ if (status?.setupNewState.modelProvider) {
+ setPendingModelProvider(null);
+ }
+ }, [status?.setupNewState.modelProvider]);
+
+ useEffect(() => {
+ // The setup token doubles as the system invite for the first admin
+ // account, so it has to reach the sign-up request as the invite cookie.
+ // Only persist it once the server confirms it, so a stale or mistyped
+ // ?token= can never clobber a still-valid cookie.
+ if (!isSignedIn && setupToken && bootstrapStatus?.setupTokenSatisfied) {
+ document.cookie = `${INVITE_COOKIE_NAME}=${encodeURIComponent(setupToken)}; path=/; max-age=3600; SameSite=Lax`;
+ }
+ }, [bootstrapStatus?.setupTokenSatisfied, isSignedIn, setupToken]);
+
+ if (!isSignedIn) {
+ if (isBootstrapLoading || !bootstrapStatus?.setupOpen) {
+ return (
+
+
+
+ );
+ }
+
+ if (
+ (bootstrapStatus.setupTokenRequired &&
+ !bootstrapStatus.setupTokenSatisfied) ||
+ bootstrapStatus.authSetup == null
+ ) {
+ return (
+
+ setSetupToken(nextSetupToken)}
+ />
+
+ );
+ }
+
+ return (
+
+
+ ({
+ opacity: 0,
+ y: direction === 'forward' ? 20 : -20,
+ }),
+ center: {
+ opacity: 1,
+ y: 0,
+ transition: { duration: 0.25, ease: 'easeOut' },
+ },
+ exit: (direction) => ({
+ opacity: 0,
+ y: direction === 'forward' ? -20 : 20,
+ transition: { duration: 0.25, ease: 'easeOut' },
+ }),
+ }}
+ initial="enter"
+ animate="center"
+ exit="exit"
+ >
+ {bootstrapStep === 'welcome' && (
+ {
+ setBootstrapStepWithTransition(
+ getBootstrapStepAfterWelcome(bootstrapStatus.authSetup),
+ );
+ }}
+ />
+ )}
+ {bootstrapStep === 'email-account' && (
+ {
+ setPendingAuthProvider(provider);
+ setBootstrapStepWithTransition(
+ getNextBootstrapStep(bootstrapStatus.authSetup, provider),
+ );
+ }}
+ onUseEmailPassword={() =>
+ setBootstrapStepWithTransition('email-password')
+ }
+ />
+ )}
+ {bootstrapStep === 'email-password' && (
+ setBootstrapStepWithTransition('email-account')}
+ />
+ )}
+ {bootstrapStep === 'auth-provider' && (
+ {
+ if (provider === 'telegram' || provider === 'discord') {
+ return;
+ }
+ setPendingAuthProvider(provider);
+ setBootstrapStepWithTransition('auth-env-vars');
+ }}
+ onBack={() => setBootstrapStepWithTransition('email-account')}
+ />
+ )}
+ {bootstrapStep === 'auth-env-vars' && (
+ undefined}
+ onBack={() => setBootstrapStepWithTransition('auth-provider')}
+ bootstrapMode={true}
+ setupToken={setupToken}
+ />
+ )}
+
+
+
+ );
+ }
+
+ if (!isSignedIn || !isAdmin) {
+ return null;
+ }
+
+ if (isError) {
+ return (
+
+
+ Unable to load setup. Redirecting you to Home.
+
+
+
+ );
+ }
+
+ if (isLoading || !status) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ ({
+ opacity: 0,
+ y: direction === 'forward' ? 20 : -20,
+ }),
+ center: {
+ opacity: 1,
+ y: 0,
+ transition: { duration: 0.25, ease: 'easeOut' },
+ },
+ exit: (direction) => ({
+ opacity: 0,
+ y: direction === 'forward' ? -20 : 20,
+ transition: { duration: 0.25, ease: 'easeOut' },
+ }),
+ }}
+ initial="enter"
+ animate="center"
+ exit="exit"
+ >
+ {step === 'welcome' && }
+ {step === 'auth-provider' && (
+ {
+ // Telegram and Discord are UI-only choices with no persisted
+ // auth provider and their own setup steps, so keep them on
+ // the pending-only path.
+ if (provider === 'telegram' || provider === 'discord') {
+ setPendingAuthProvider(provider);
+ goToStep('auth-env-vars');
+ return;
+ }
+
+ // Persist the chosen provider first; the choice only becomes
+ // pending and navigates on a successful save, so it survives a
+ // reload and a failed save never skips the chooser with an
+ // unsaved selection.
+ saveAuthProviderChoice.mutate({ provider });
+ }}
+ onSkip={() => {
+ setupSession.setCommunicationStepState('skipped');
+ goToNextStep();
+ }}
+ onBack={canGoBack ? goToPreviousStep : undefined}
+ disabled={saveAuthProviderChoice.isPending}
+ />
+ )}
+ {step === 'auth-env-vars' &&
+ (pendingAuthProvider === 'telegram' ? (
+ {
+ setupSession.setCommunicationStepState('completed');
+ setPendingAuthProvider(null);
+ goToNextStep();
+ }}
+ onBack={
+ canGoBack
+ ? () => {
+ setPendingAuthProvider(null);
+ goToPreviousStep();
+ }
+ : undefined
+ }
+ />
+ ) : pendingAuthProvider === 'discord' ? (
+ {
+ setupSession.setCommunicationStepState('completed');
+ setPendingAuthProvider(null);
+ goToNextStep();
+ }}
+ onBack={
+ canGoBack
+ ? () => {
+ setPendingAuthProvider(null);
+ goToPreviousStep();
+ }
+ : undefined
+ }
+ />
+ ) : (
+ {
+ setPendingAuthProvider(null);
+ goToNextStep();
+ }}
+ onBack={() => {
+ setPendingAuthProvider(null);
+ if (canGoBack) {
+ goToPreviousStep();
+ return;
+ }
+
+ goToStep('auth-provider');
+ }}
+ bootstrapMode={false}
+ />
+ ))}
+ {step === 'env-vars' && (
+ {
+ setPendingModelProvider(null);
+ goToNextStep();
+ }}
+ onBack={canGoBack ? goToPreviousStep : undefined}
+ onSelectedProviderChange={setPendingModelProvider}
+ />
+ )}
+ {step === 'source-control-provider' && (
+ {
+ saveSourceControlProviderChoice.mutate({ provider });
+ }}
+ onBack={canGoBack ? goToPreviousStep : undefined}
+ disabled={saveSourceControlProviderChoice.isPending}
+ />
+ )}
+ {step === 'source-control-config' && (
+ {
+ setPendingSourceControlProvider(null);
+ goToStep('source-control-connect');
+ }}
+ onBack={() => {
+ setPendingSourceControlProvider(null);
+ if (canGoBack) {
+ goToPreviousStep();
+ return;
+ }
+
+ goToStep('source-control-provider');
+ }}
+ />
+ )}
+ {step === 'source-control-connect' && (
+
+ )}
+ {step === 'compute-provider' && (
+ {
+ saveComputeProviderChoice.mutate({ provider });
+ }}
+ onBack={canGoBack ? goToPreviousStep : undefined}
+ disabled={saveComputeProviderChoice.isPending}
+ />
+ )}
+ {step === 'compute-config' && (
+ {
+ setPendingComputeProvider(null);
+ goToNextStep();
+ }}
+ onBack={
+ canGoBack
+ ? () => {
+ setPendingComputeProvider(null);
+ goToPreviousStep();
+ }
+ : undefined
+ }
+ />
+ )}
+ {step === 'slack' && (
+ {
+ setupSession.setCommunicationStepState('skipped');
+ goToNextStep();
+ }}
+ onBack={canGoBack ? goToPreviousStep : undefined}
+ returnPath={getSetupStepPath('slack')}
+ />
+ )}
+ {step === 'repo-selection' && (
+
+ goToStep('compute-config', { revisit: true })
+ }
+ onReviewComputeProvider={() =>
+ goToStep('compute-provider', { revisit: true })
+ }
+ onContinue={() => goToStep('invoke')}
+ onBack={canGoBack ? goToPreviousStep : undefined}
+ onSkip={() => {
+ setupSession.unlockPostOnboardingFlow();
+ goToNextPostOnboardingStep(true);
+ }}
+ />
+ )}
+ {step === 'invoke' && (
+
+ goToStep('compute-config', { revisit: true })
+ }
+ onTryItOut={() => undefined}
+ />
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx b/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx
index ba4b127a1..94708e1e0 100644
--- a/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx
+++ b/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useEffect, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import {
@@ -69,17 +69,26 @@ export function StepInferenceProvider({
openRouterOauthErrorReason = null,
onContinue,
onBack,
+ onSelectedProviderChange,
}: {
modelSetup: SetupModelStatus;
openRouterOauthStatus?: OpenRouterOauthEntryStatus | null;
openRouterOauthErrorReason?: string | null;
onContinue: () => void;
onBack?: () => void;
+ onSelectedProviderChange?: (provider: SetupModelProviderId | null) => void;
}) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const [selectedProvider, setSelectedProvider] =
useState(null);
+ const selectProvider = useCallback(
+ (provider: SetupModelProviderId | null) => {
+ setSelectedProvider(provider);
+ onSelectedProviderChange?.(provider);
+ },
+ [onSelectedProviderChange],
+ );
const [apiKey, setApiKey] = useState('');
const [connectionName, setConnectionName] = useState('');
const [additionalEnvValues, setAdditionalEnvValues] = useState<
@@ -132,7 +141,7 @@ export function StepInferenceProvider({
useEffect(() => {
if (openRouterOauthStatus === 'connected') {
- setSelectedProvider('openrouter');
+ selectProvider('openrouter');
toast.success(
'Connected to OpenRouter. Your new API key has been saved.',
{
@@ -144,7 +153,7 @@ export function StepInferenceProvider({
id: 'openrouter-oauth-result',
});
}
- }, [openRouterOauthStatus, openRouterOauthErrorReason]);
+ }, [openRouterOauthStatus, openRouterOauthErrorReason, selectProvider]);
const selectedProviderStatus = useMemo(
() => getProviderStatus(modelSetup, selectedProvider),
@@ -298,7 +307,7 @@ export function StepInferenceProvider({