From e6d55c4d602ec8d11724569b52734db4b1194c5a Mon Sep 17 00:00:00 2001 From: Bob Gregor Date: Mon, 27 Apr 2026 17:15:09 -0400 Subject: [PATCH 1/2] feat(frontend): add FTUE welcome wizard for first-time users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a multi-step onboarding wizard that guides new users through workspace creation, integration setup, and starting their first session. - 4-step wizard: Welcome → Create Workspace → Connect Integrations → Completion - Dynamic integration registry (INTEGRATION_REGISTRY) with compile-time completeness guard -- new integrations only need one registry entry - Extracted WorkspaceForm with callback interface (shared between wizard and CreateWorkspaceDialog, no behavior change for existing users) - useShouldShowOnboarding hook: triggers on zero workspaces + localStorage flag - Responsive layout: 1-col (mobile) → 2-col (tablet) → 3-col (desktop) - Server config via meta tags (github-app-slug, github-callback-url) - 19 new tests (hook, registry, wizard transitions) - Documentation: COMPONENT_PATTERNS.md and DEVELOPMENT.md updated with extension guide for humans and agents Made-with: Cursor --- components/frontend/COMPONENT_PATTERNS.md | 41 +++ components/frontend/DEVELOPMENT.md | 14 ++ components/frontend/src/app/layout.tsx | 2 + components/frontend/src/app/projects/page.tsx | 16 ++ .../components/create-workspace-dialog.tsx | 236 ++---------------- .../__tests__/integration-registry.test.ts | 94 +++++++ .../use-should-show-onboarding.test.ts | 87 +++++++ .../__tests__/welcome-wizard.test.tsx | 92 +++++++ .../onboarding/integration-registry.ts | 132 ++++++++++ .../onboarding/steps/completion-step.tsx | 65 +++++ .../steps/create-workspace-step.tsx | 51 ++++ .../onboarding/steps/integrations-step.tsx | 134 ++++++++++ .../onboarding/steps/welcome-step.tsx | 47 ++++ .../components/onboarding/use-app-config.ts | 27 ++ .../onboarding/use-should-show-onboarding.ts | 46 ++++ .../components/onboarding/welcome-wizard.tsx | 107 ++++++++ .../src/components/workspace-form.tsx | 200 +++++++++++++++ 17 files changed, 1170 insertions(+), 221 deletions(-) create mode 100644 components/frontend/src/components/onboarding/__tests__/integration-registry.test.ts create mode 100644 components/frontend/src/components/onboarding/__tests__/use-should-show-onboarding.test.ts create mode 100644 components/frontend/src/components/onboarding/__tests__/welcome-wizard.test.tsx create mode 100644 components/frontend/src/components/onboarding/integration-registry.ts create mode 100644 components/frontend/src/components/onboarding/steps/completion-step.tsx create mode 100644 components/frontend/src/components/onboarding/steps/create-workspace-step.tsx create mode 100644 components/frontend/src/components/onboarding/steps/integrations-step.tsx create mode 100644 components/frontend/src/components/onboarding/steps/welcome-step.tsx create mode 100644 components/frontend/src/components/onboarding/use-app-config.ts create mode 100644 components/frontend/src/components/onboarding/use-should-show-onboarding.ts create mode 100644 components/frontend/src/components/onboarding/welcome-wizard.tsx create mode 100644 components/frontend/src/components/workspace-form.tsx 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. -
- {/* Cluster info banner */} - {!clusterLoading && !isOpenShift && ( - - - - Running on vanilla Kubernetes. Display name and description - fields are not available. - - - )} - - {/* Basic Information */} -
- - {/* OpenShift-only fields */} - {isOpenShift && ( -
- - handleDisplayNameChange(e.target.value)} - placeholder="e.g. My Research Workspace" - maxLength={100} - /> -
- )} - - {/* Vanilla Kubernetes name field */} - {!isOpenShift && ( -
- - { - const name = e.target.value; - setFormData((prev) => ({ ...prev, name })); - setNameError(validateProjectName(name)); - }} - placeholder="my-research-workspace" - className={nameError ? "border-red-500" : ""} - /> - {nameError &&

{nameError}

} -

- Lowercase alphanumeric with hyphens. -

-
- )} - - {/* OpenShift-only description field */} - {isOpenShift && ( -
- -