diff --git a/components/frontend/COMPONENT_PATTERNS.md b/components/frontend/COMPONENT_PATTERNS.md
index cbd3bde9a..df7e70b6c 100644
--- a/components/frontend/COMPONENT_PATTERNS.md
+++ b/components/frontend/COMPONENT_PATTERNS.md
@@ -381,6 +381,47 @@ staleTime: Infinity
"Error: ECONNREFUSED 127.0.0.1:3000"
```
+## Onboarding Wizard
+
+The FTUE (first-time user experience) wizard lives in `src/components/onboarding/`. It is a dedicated shared component (deliberate exception to colocation) because it may be triggered from multiple entry points.
+
+### Architecture
+
+```
+components/onboarding/
+ welcome-wizard.tsx # Thin state machine shell (<100 lines)
+ integration-registry.ts # INTEGRATION_REGISTRY + IntegrationEntry type
+ use-should-show-onboarding.ts # Trigger hook (zero projects + localStorage)
+ use-app-config.ts # Reads server config from tags
+ steps/
+ welcome-step.tsx # Step 1: intro
+ create-workspace-step.tsx # Step 2: workspace creation
+ integrations-step.tsx # Step 3: connect integrations
+ completion-step.tsx # Step 4: redirect CTAs
+```
+
+### WelcomeWizard composition
+
+The wizard shell is a thin state machine driven by a `STEPS` array. Each step receives `WizardStepProps` (`onNext`, `onSkip`, `wizardState`) and is self-contained. To add a new step, create the component and append it to the `STEPS` array.
+
+### Integration Registry (INTEGRATION_REGISTRY)
+
+A typed array of `IntegrationEntry` objects. Each entry provides `id`, `name`, `description`, `isConnected(status)`, and `renderCard(props)`. The integrations step iterates the registry; it never imports individual `*ConnectionCard` components directly.
+
+A compile-time guard ensures every key of `IntegrationsStatus` (excluding `mcpServers`) has a registry entry. Adding a new integration to the API type without a registry entry causes a build error.
+
+### WorkspaceForm callback interface
+
+`WorkspaceForm` (in `src/components/workspace-form.tsx`) does NOT own any mutation. It exposes `onSubmit(data)`, `onError(err)`, and `isSubmitting` callbacks so consumers control what happens on success. Both `CreateWorkspaceDialog` and the onboarding wizard use the same form.
+
+### Invariants (agent review checklist)
+
+- `welcome-wizard.tsx` must stay under 100 lines
+- `WorkspaceForm` must not import `useCreateProject`
+- `integrations-step.tsx` must not import individual connection cards
+- `INTEGRATION_REGISTRY` must cover all non-MCP keys of `IntegrationsStatus`
+- Wizard state persisted to `sessionStorage` for GitHub OAuth redirect must be cleared on completion/dismissal
+
## Summary
Key patterns:
diff --git a/components/frontend/DEVELOPMENT.md b/components/frontend/DEVELOPMENT.md
index 7372f4c20..c73db76d5 100644
--- a/components/frontend/DEVELOPMENT.md
+++ b/components/frontend/DEVELOPMENT.md
@@ -208,6 +208,20 @@ The `+` button dropdown in `new-session-view.tsx` is the single entry point for
New options flow: `onCreateSession` config → `page.tsx` → `createSessionMutation.mutate()` → POST `/api/projects/{project}/agentic-sessions` → backend handler → CR spec. For booleans, add to `CreateAgenticSessionRequest` in `types/session.go`. For complex options, serialize to env var on the CR and parse in the runner.
+### Adding an Integration to Onboarding
+
+When adding a new `*ConnectionCard` to the platform:
+
+1. Add the status field to `IntegrationsStatus` in `src/services/api/integrations.ts`.
+2. Add one `IntegrationEntry` to `INTEGRATION_REGISTRY` in `src/components/onboarding/integration-registry.ts`:
+ - `id` matching the new key in `IntegrationsStatus`
+ - `name`, `description` for display
+ - `isConnected` function deriving boolean from the status shape
+ - `renderCard` wrapping the new `*ConnectionCard` component
+3. No other onboarding files need to change. The integrations step iterates the registry automatically.
+
+The compile-time guard will error if you add a status field without a registry entry.
+
## Pre-Commit Checklist
- [ ] Zero `any` types (or justified with eslint-disable)
diff --git a/components/frontend/src/app/layout.tsx b/components/frontend/src/app/layout.tsx
index b30787eac..32179d696 100755
--- a/components/frontend/src/app/layout.tsx
+++ b/components/frontend/src/app/layout.tsx
@@ -37,6 +37,8 @@ export default function RootLayout({
+
+
{/* suppressHydrationWarning is needed here as well since ThemeProvider modifies the class attribute */}
diff --git a/components/frontend/src/app/projects/page.tsx b/components/frontend/src/app/projects/page.tsx
index 9cd44a73a..9618d2fd5 100644
--- a/components/frontend/src/app/projects/page.tsx
+++ b/components/frontend/src/app/projects/page.tsx
@@ -39,6 +39,8 @@ import { EmptyState } from '@/components/empty-state';
import { ErrorMessage } from '@/components/error-message';
import { DestructiveConfirmationDialog } from '@/components/confirmation-dialog';
import { CreateWorkspaceDialog } from '@/components/create-workspace-dialog';
+import { WelcomeWizard } from '@/components/onboarding/welcome-wizard';
+import { useShouldShowOnboarding } from '@/components/onboarding/use-should-show-onboarding';
import { toast } from 'sonner';
import type { Project } from '@/types/api';
import { DEFAULT_PAGE_SIZE } from '@/types/api';
@@ -48,6 +50,14 @@ export default function ProjectsPage() {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [projectToDelete, setProjectToDelete] = useState(null);
const [showCreateDialog, setShowCreateDialog] = useState(false);
+ const { shouldShow, dismiss: dismissOnboarding } = useShouldShowOnboarding();
+ const [wizardOpen, setWizardOpen] = useState(false);
+
+ // Open the wizard when the hook says to, but don't close it when projects appear
+ // (creating a workspace in step 2 would otherwise cause the wizard to close mid-flow)
+ useEffect(() => {
+ if (shouldShow) setWizardOpen(true);
+ }, [shouldShow]);
// Pagination and search state
const [searchInput, setSearchInput] = useState('');
@@ -355,6 +365,12 @@ export default function ProjectsPage() {
open={showCreateDialog}
onOpenChange={setShowCreateDialog}
/>
+
+ {/* Onboarding wizard for first-time users */}
+ { setWizardOpen(false); dismissOnboarding(); }}
+ />
);
diff --git a/components/frontend/src/components/create-workspace-dialog.tsx b/components/frontend/src/components/create-workspace-dialog.tsx
index 106e72642..8971b8307 100644
--- a/components/frontend/src/components/create-workspace-dialog.tsx
+++ b/components/frontend/src/components/create-workspace-dialog.tsx
@@ -2,24 +2,17 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
- DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
-import { CreateProjectRequest } from "@/types/project";
-import { Save, Loader2, Info } from "lucide-react";
import { toast } from "sonner";
import { useCreateProject } from "@/services/queries";
-import { useClusterInfo } from "@/hooks/use-cluster-info";
-import { Alert, AlertDescription } from "@/components/ui/alert";
+import { WorkspaceForm } from "@/components/workspace-form";
+import type { CreateProjectRequest } from "@/types/project";
type CreateWorkspaceDialogProps = {
open: boolean;
@@ -32,123 +25,23 @@ export function CreateWorkspaceDialog({
}: CreateWorkspaceDialogProps) {
const router = useRouter();
const createProjectMutation = useCreateProject();
- const { isOpenShift, isLoading: clusterLoading } = useClusterInfo();
const [error, setError] = useState(null);
- const [formData, setFormData] = useState({
- name: "",
- displayName: "",
- description: "",
- });
-
- const [nameError, setNameError] = useState(null);
- const [manuallyEditedName, setManuallyEditedName] = useState(false);
-
- const generateWorkspaceName = (displayName: string): string => {
- return displayName
- .toLowerCase()
- .replace(/\s+/g, "-") // Replace spaces with hyphens
- .replace(/[^a-z0-9-]/g, "") // Remove invalid characters
- .replace(/-+/g, "-") // Collapse multiple hyphens
- .replace(/^-+|-+$/g, "") // Remove leading/trailing hyphens
- .slice(0, 63); // Truncate to max length
- };
-
- const validateProjectName = (name: string) => {
- // Validate name pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
- const namePattern = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/;
-
- if (!name) {
- return "Workspace name is required";
- }
-
- if (name.length > 63) {
- return "Workspace name must be 63 characters or less";
- }
-
- if (!namePattern.test(name)) {
- return "Workspace name must be lowercase alphanumeric with hyphens (cannot start or end with hyphen)";
- }
-
- return null;
- };
-
- const handleDisplayNameChange = (displayName: string) => {
- setFormData((prev) => ({
- ...prev,
- displayName,
- // Auto-generate name only if it hasn't been manually edited
- name: manuallyEditedName ? prev.name : generateWorkspaceName(displayName),
- }));
-
- // Validate the auto-generated name
- if (!manuallyEditedName) {
- const generatedName = generateWorkspaceName(displayName);
- setNameError(validateProjectName(generatedName));
- }
- };
-
- // Commented out - name input field is hidden, auto-generated from displayName
- // const handleNameChange = (name: string) => {
- // setManuallyEditedName(true);
- // setFormData((prev) => ({ ...prev, name }));
- // setNameError(validateProjectName(name));
- // };
-
- const resetForm = () => {
- setFormData({
- name: "",
- displayName: "",
- description: "",
- });
- setNameError(null);
- setError(null);
- setManuallyEditedName(false);
- };
const handleClose = () => {
if (!createProjectMutation.isPending) {
- resetForm();
+ setError(null);
onOpenChange(false);
}
};
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
-
- // Validate required fields
- if (isOpenShift && !formData.displayName?.trim()) {
- setError("Display Name is required");
- return;
- }
-
- const nameValidationError = validateProjectName(formData.name);
- if (nameValidationError) {
- setNameError(nameValidationError);
- return;
- }
-
+ const handleSubmit = (payload: CreateProjectRequest) => {
setError(null);
-
- // Prepare the request payload
- const payload: CreateProjectRequest = {
- name: formData.name,
- // Only include displayName and description on OpenShift
- ...(isOpenShift &&
- formData.displayName?.trim() && {
- displayName: formData.displayName.trim(),
- }),
- ...(isOpenShift &&
- formData.description?.trim() && {
- description: formData.description.trim(),
- }),
- };
-
createProjectMutation.mutate(payload, {
onSuccess: (project) => {
toast.success(
- `Workspace "${formData.displayName || formData.name}" created successfully`
+ `Workspace "${payload.displayName || payload.name}" created successfully`
);
- resetForm();
+ setError(null);
onOpenChange(false);
router.push(`/projects/${encodeURIComponent(project.name)}`);
},
@@ -167,117 +60,18 @@ export function CreateWorkspaceDialog({
Create New Workspace
- A workspace is an isolated environment where your team can create and manage AI-powered agentic sessions. Each workspace has its own settings, permissions, and resources.
+ A workspace is an isolated environment where your team can create and
+ manage AI-powered agentic sessions. Each workspace has its own
+ settings, permissions, and resources.
-
+
);
diff --git a/components/frontend/src/components/onboarding/__tests__/integration-registry.test.ts b/components/frontend/src/components/onboarding/__tests__/integration-registry.test.ts
new file mode 100644
index 000000000..aca0d8d30
--- /dev/null
+++ b/components/frontend/src/components/onboarding/__tests__/integration-registry.test.ts
@@ -0,0 +1,94 @@
+import { describe, it, expect } from 'vitest';
+import { INTEGRATION_REGISTRY } from '../integration-registry';
+import type { IntegrationsStatus } from '@/services/api/integrations';
+
+function makeStatus(overrides: Partial = {}): IntegrationsStatus {
+ return {
+ github: {
+ installed: false,
+ pat: { configured: false },
+ },
+ gitlab: { connected: false },
+ google: { connected: false },
+ jira: { connected: false },
+ coderabbit: { connected: false },
+ gerrit: { connected: false },
+ ...overrides,
+ };
+}
+
+describe('INTEGRATION_REGISTRY', () => {
+ it('has an entry for every non-mcpServers key of IntegrationsStatus', () => {
+ const registryIds = INTEGRATION_REGISTRY.map((e) => e.id);
+ const expectedKeys: string[] = ['github', 'gitlab', 'google', 'jira', 'coderabbit', 'gerrit'];
+ expect(registryIds.sort()).toEqual(expectedKeys.sort());
+ });
+
+ it('github: detects App install', () => {
+ const entry = INTEGRATION_REGISTRY.find((e) => e.id === 'github')!;
+ expect(entry.isConnected(makeStatus({ github: { installed: true, pat: { configured: false } } }))).toBe(true);
+ expect(entry.isConnected(makeStatus())).toBe(false);
+ });
+
+ it('github: detects PAT', () => {
+ const entry = INTEGRATION_REGISTRY.find((e) => e.id === 'github')!;
+ expect(entry.isConnected(makeStatus({ github: { installed: false, pat: { configured: true } } }))).toBe(true);
+ });
+
+ it('gitlab: detects connected', () => {
+ const entry = INTEGRATION_REGISTRY.find((e) => e.id === 'gitlab')!;
+ expect(entry.isConnected(makeStatus({ gitlab: { connected: true } }))).toBe(true);
+ expect(entry.isConnected(makeStatus())).toBe(false);
+ });
+
+ it('google: detects connected', () => {
+ const entry = INTEGRATION_REGISTRY.find((e) => e.id === 'google')!;
+ expect(entry.isConnected(makeStatus({ google: { connected: true } }))).toBe(true);
+ expect(entry.isConnected(makeStatus())).toBe(false);
+ });
+
+ it('jira: detects connected', () => {
+ const entry = INTEGRATION_REGISTRY.find((e) => e.id === 'jira')!;
+ expect(entry.isConnected(makeStatus({ jira: { connected: true } }))).toBe(true);
+ expect(entry.isConnected(makeStatus())).toBe(false);
+ });
+
+ it('coderabbit: detects connected', () => {
+ const entry = INTEGRATION_REGISTRY.find((e) => e.id === 'coderabbit')!;
+ expect(entry.isConnected(makeStatus({ coderabbit: { connected: true } }))).toBe(true);
+ expect(entry.isConnected(makeStatus())).toBe(false);
+ });
+
+ it('gerrit: detects connected instance', () => {
+ const entry = INTEGRATION_REGISTRY.find((e) => e.id === 'gerrit')!;
+ expect(
+ entry.isConnected(
+ makeStatus({
+ gerrit: {
+ connected: false,
+ instances: [
+ { instanceName: 'g1', url: 'https://g.example.com', authMethod: 'http_basic', connected: true },
+ ],
+ },
+ })
+ )
+ ).toBe(true);
+ expect(entry.isConnected(makeStatus())).toBe(false);
+ });
+
+ it('gerrit: false when no connected instances', () => {
+ const entry = INTEGRATION_REGISTRY.find((e) => e.id === 'gerrit')!;
+ expect(
+ entry.isConnected(
+ makeStatus({
+ gerrit: {
+ connected: false,
+ instances: [
+ { instanceName: 'g1', url: 'https://g.example.com', authMethod: 'http_basic', connected: false },
+ ],
+ },
+ })
+ )
+ ).toBe(false);
+ });
+});
diff --git a/components/frontend/src/components/onboarding/__tests__/use-should-show-onboarding.test.ts b/components/frontend/src/components/onboarding/__tests__/use-should-show-onboarding.test.ts
new file mode 100644
index 000000000..e5dccb1bd
--- /dev/null
+++ b/components/frontend/src/components/onboarding/__tests__/use-should-show-onboarding.test.ts
@@ -0,0 +1,89 @@
+import { renderHook, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import React from 'react';
+import { useShouldShowOnboarding, ONBOARDING_FLAG } from '../use-should-show-onboarding';
+
+vi.mock('@/services/api/projects', () => ({
+ listProjectsPaginated: vi.fn(),
+}));
+
+import * as projectsApi from '@/services/api/projects';
+const mockListPaginated = vi.mocked(projectsApi.listProjectsPaginated);
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const Wrapper = ({ children }: { children: React.ReactNode }) =>
+ React.createElement(QueryClientProvider, { client: queryClient }, children);
+ Wrapper.displayName = 'TestQueryWrapper';
+ return Wrapper;
+}
+
+describe('useShouldShowOnboarding', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ const emptyResponse = { items: [], totalCount: 0, hasMore: false, limit: 1, offset: 0 };
+
+ const projectResponse = {
+ items: [{ name: 'proj', displayName: 'Proj', labels: {}, annotations: {}, creationTimestamp: '', status: 'active' as const, isOpenShift: false }],
+ totalCount: 1,
+ hasMore: false,
+ limit: 1,
+ offset: 0,
+ };
+
+ it('shows wizard when zero projects and no localStorage flag', async () => {
+ mockListPaginated.mockResolvedValue(emptyResponse);
+ const wrapper = createWrapper();
+ const { result } = renderHook(() => useShouldShowOnboarding(), { wrapper });
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.shouldShow).toBe(true);
+ });
+
+ it('hides wizard when zero projects but localStorage flag is set', async () => {
+ localStorage.setItem(ONBOARDING_FLAG, 'true');
+ mockListPaginated.mockResolvedValue(emptyResponse);
+ const wrapper = createWrapper();
+ const { result } = renderHook(() => useShouldShowOnboarding(), { wrapper });
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.shouldShow).toBe(false);
+ });
+
+ it('hides wizard when user has projects', async () => {
+ mockListPaginated.mockResolvedValue(projectResponse);
+ const wrapper = createWrapper();
+ const { result } = renderHook(() => useShouldShowOnboarding(), { wrapper });
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.shouldShow).toBe(false);
+ });
+
+ it('hides wizard when user has projects even without localStorage flag', async () => {
+ mockListPaginated.mockResolvedValue(projectResponse);
+ const wrapper = createWrapper();
+ const { result } = renderHook(() => useShouldShowOnboarding(), { wrapper });
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.shouldShow).toBe(false);
+ });
+
+ it('dismiss sets the localStorage flag', async () => {
+ mockListPaginated.mockResolvedValue(emptyResponse);
+ const wrapper = createWrapper();
+ const { result } = renderHook(() => useShouldShowOnboarding(), { wrapper });
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ result.current.dismiss();
+ expect(localStorage.getItem(ONBOARDING_FLAG)).toBe('true');
+ });
+});
diff --git a/components/frontend/src/components/onboarding/__tests__/welcome-wizard.test.tsx b/components/frontend/src/components/onboarding/__tests__/welcome-wizard.test.tsx
new file mode 100644
index 000000000..706554f60
--- /dev/null
+++ b/components/frontend/src/components/onboarding/__tests__/welcome-wizard.test.tsx
@@ -0,0 +1,93 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import React from 'react';
+import { WelcomeWizard } from '../welcome-wizard';
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
+ useParams: () => ({}),
+}));
+
+vi.mock('@/services/queries', () => ({
+ useCreateProject: () => ({ mutate: vi.fn(), isPending: false }),
+}));
+
+vi.mock('@/services/queries/use-integrations', () => ({
+ useIntegrationsStatus: () => ({
+ data: {
+ github: { installed: false, pat: { configured: false } },
+ gitlab: { connected: false },
+ google: { connected: false },
+ jira: { connected: false },
+ coderabbit: { connected: false },
+ gerrit: { connected: false },
+ },
+ isLoading: false,
+ refetch: vi.fn(),
+ }),
+}));
+
+vi.mock('@/hooks/use-cluster-info', () => ({
+ useClusterInfo: () => ({ isOpenShift: false, isLoading: false, vertexEnabled: false }),
+}));
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const Wrapper = ({ children }: { children: React.ReactNode }) =>
+ React.createElement(QueryClientProvider, { client: queryClient }, children);
+ Wrapper.displayName = 'TestQueryWrapper';
+ return Wrapper;
+}
+
+function renderWizard(onDismiss = vi.fn()) {
+ const Wrapper = createWrapper();
+ return {
+ onDismiss,
+ ...render(
+ React.createElement(Wrapper, null,
+ React.createElement(WelcomeWizard, { open: true, onDismiss })
+ )
+ ),
+ };
+}
+
+describe('WelcomeWizard', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ sessionStorage.clear();
+ });
+
+ it('renders step 1 (Welcome) initially', () => {
+ renderWizard();
+ expect(screen.getByText('Welcome to Ambient Code Platform')).toBeTruthy();
+ expect(screen.getByText('Get Started')).toBeTruthy();
+ });
+
+ it('advances to step 2 on Get Started click', () => {
+ renderWizard();
+ fireEvent.click(screen.getByText('Get Started'));
+ expect(screen.getByText('Create your workspace')).toBeTruthy();
+ });
+
+ it('shows Skip setup link on step 2', () => {
+ renderWizard();
+ fireEvent.click(screen.getByText('Get Started'));
+ expect(screen.getByText('Skip setup')).toBeTruthy();
+ });
+
+ it('calls onDismiss when Skip setup is clicked', () => {
+ const onDismiss = vi.fn();
+ renderWizard(onDismiss);
+ fireEvent.click(screen.getByText('Get Started'));
+ fireEvent.click(screen.getByText('Skip setup'));
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not show Skip setup on step 1', () => {
+ renderWizard();
+ expect(screen.queryByText('Skip setup')).toBeNull();
+ });
+});
diff --git a/components/frontend/src/components/onboarding/integration-registry.ts b/components/frontend/src/components/onboarding/integration-registry.ts
new file mode 100644
index 000000000..4a1c2594d
--- /dev/null
+++ b/components/frontend/src/components/onboarding/integration-registry.ts
@@ -0,0 +1,132 @@
+"use client";
+
+import React from "react";
+import type { IntegrationsStatus } from "@/services/api/integrations";
+import { GitHubConnectionCard } from "@/components/github-connection-card";
+import { GitLabConnectionCard } from "@/components/gitlab-connection-card";
+import { GoogleDriveConnectionCard } from "@/components/google-drive-connection-card";
+import { JiraConnectionCard } from "@/components/jira-connection-card";
+import { CodeRabbitConnectionCard } from "@/components/coderabbit-connection-card";
+import { GerritConnectionCard } from "@/components/gerrit-connection-card";
+import { useAppConfig } from "./use-app-config";
+
+/**
+ * Describes a single platform integration for dynamic rendering.
+ *
+ * To add a new integration:
+ * 1. Add its status field to `IntegrationsStatus` in `services/api/integrations.ts`.
+ * 2. Add an `IntegrationEntry` here with a matching `id`.
+ * 3. The onboarding wizard and any other registry consumer picks it up automatically.
+ */
+export type IntegrationEntry = {
+ /** Must match a key of IntegrationsStatus (excluding mcpServers). */
+ id: IntegrationStatusKey;
+ name: string;
+ description: string;
+ /** Derive a boolean "connected" signal from the heterogeneous status shape. */
+ isConnected: (status: IntegrationsStatus) => boolean;
+ /** Render the integration's connection card. */
+ renderCard: (props: {
+ status: IntegrationsStatus;
+ onRefresh: () => void;
+ appConfig: AppConfig;
+ }) => React.ReactNode;
+};
+
+/** All IntegrationsStatus keys that represent real integrations (not mcpServers). */
+type IntegrationStatusKey = Exclude;
+
+/** Server-side config values exposed via meta tags. */
+export type AppConfig = {
+ githubAppSlug: string;
+ githubCallbackUrl: string;
+};
+
+/**
+ * Compile-time completeness guard: ensures every integration status key has a
+ * registry entry. If a new key is added to IntegrationsStatus without a
+ * corresponding entry here, TypeScript will report an error.
+ */
+type AssertComplete =
+ IntegrationStatusKey extends T[number]["id"] ? T : never;
+
+const _REGISTRY = [
+ {
+ id: "github" as const,
+ name: "GitHub",
+ description: "Connect repositories and pull requests",
+ isConnected: (status: IntegrationsStatus) =>
+ status.github?.installed || status.github?.pat?.configured || false,
+ renderCard: ({ status, onRefresh, appConfig }) =>
+ React.createElement(GitHubConnectionCard, {
+ appSlug: appConfig.githubAppSlug || undefined,
+ githubCallbackUrl: appConfig.githubCallbackUrl || undefined,
+ showManageButton: true,
+ status: status.github,
+ onRefresh,
+ }),
+ },
+ {
+ id: "gitlab" as const,
+ name: "GitLab",
+ description: "Connect GitLab repositories and merge requests",
+ isConnected: (status: IntegrationsStatus) =>
+ status.gitlab?.connected || false,
+ renderCard: ({ status, onRefresh }) =>
+ React.createElement(GitLabConnectionCard, {
+ status: status.gitlab,
+ onRefresh,
+ }),
+ },
+ {
+ id: "google" as const,
+ name: "Google Drive",
+ description: "Access documents and files from Google Drive",
+ isConnected: (status: IntegrationsStatus) =>
+ status.google?.connected || false,
+ renderCard: ({ status, onRefresh }) =>
+ React.createElement(GoogleDriveConnectionCard, {
+ showManageButton: true,
+ status: status.google,
+ onRefresh,
+ }),
+ },
+ {
+ id: "jira" as const,
+ name: "Jira",
+ description: "Link issues and track work from Jira",
+ isConnected: (status: IntegrationsStatus) =>
+ status.jira?.connected || false,
+ renderCard: ({ status, onRefresh }) =>
+ React.createElement(JiraConnectionCard, {
+ status: status.jira,
+ onRefresh,
+ }),
+ },
+ {
+ id: "coderabbit" as const,
+ name: "CodeRabbit",
+ description: "AI-powered code review integration",
+ isConnected: (status: IntegrationsStatus) =>
+ status.coderabbit?.connected || false,
+ renderCard: ({ status, onRefresh }) =>
+ React.createElement(CodeRabbitConnectionCard, {
+ status: status.coderabbit,
+ onRefresh,
+ }),
+ },
+ {
+ id: "gerrit" as const,
+ name: "Gerrit",
+ description: "Connect Gerrit instances for code review",
+ isConnected: (status: IntegrationsStatus) =>
+ (status.gerrit?.instances ?? []).some((i) => i.connected),
+ renderCard: ({ onRefresh }) =>
+ React.createElement(GerritConnectionCard, { onRefresh }),
+ },
+] as const satisfies readonly IntegrationEntry[];
+
+// This line triggers a type error if _REGISTRY is missing any IntegrationStatusKey.
+export const INTEGRATION_REGISTRY: AssertComplete = _REGISTRY;
+
+export { useAppConfig };
diff --git a/components/frontend/src/components/onboarding/steps/completion-step.tsx b/components/frontend/src/components/onboarding/steps/completion-step.tsx
new file mode 100644
index 000000000..c393d3dfc
--- /dev/null
+++ b/components/frontend/src/components/onboarding/steps/completion-step.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import { CheckCircle2, MessageSquarePlus, Settings } from "lucide-react";
+import type { WizardStepProps } from "../welcome-wizard";
+
+export function CompletionStep({ onNext, wizardState }: WizardStepProps) {
+ const router = useRouter();
+ const workspaceName = wizardState.createdWorkspaceName;
+
+ const handleStartSession = () => {
+ onNext();
+ if (workspaceName) {
+ router.push(`/projects/${encodeURIComponent(workspaceName)}/new`);
+ }
+ };
+
+ const handleGoToSettings = () => {
+ onNext();
+ if (workspaceName) {
+ router.push(
+ `/projects/${encodeURIComponent(workspaceName)}/settings`
+ );
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+ You're all set!
+
+
+ Your workspace is ready. Start a session to begin working with AI, or
+ configure workspace settings like API keys and storage.
+
+ );
+}
+
+export { SESSION_KEY };
diff --git a/components/frontend/src/components/onboarding/steps/welcome-step.tsx b/components/frontend/src/components/onboarding/steps/welcome-step.tsx
new file mode 100644
index 000000000..b91eaacfa
--- /dev/null
+++ b/components/frontend/src/components/onboarding/steps/welcome-step.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { Sparkles } from "lucide-react";
+import type { WizardStepProps } from "../welcome-wizard";
+
+export function WelcomeStep({ onNext }: WizardStepProps) {
+ return (
+
+
+
+
+
+
+
+ Welcome to Ambient Code Platform
+
+
+ An AI-native platform for intelligent agentic sessions. We'll
+ help you set up your first workspace, connect your tools, and start
+ your first session.
+