Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions components/frontend/COMPONENT_PATTERNS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <meta> 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:
Expand Down
14 changes: 14 additions & 0 deletions components/frontend/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions components/frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export default function RootLayout({
<html lang="en" suppressHydrationWarning>
<head>
<meta name="backend-ws-base" content={wsBase} />
<meta name="github-app-slug" content={env.GITHUB_APP_SLUG ?? ''} />
<meta name="github-callback-url" content={process.env.GITHUB_CALLBACK_URL ?? ''} />
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this being made more global may have throw a problem for me that I wanted to share:

Screen.Recording.2026-04-27.at.4.50.04.PM.mov

Copy link
Copy Markdown
Member Author

@bobbravo2 bobbravo2 Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sessionStorage resume after GitHub OAuth redirect (code path exists but untested end-to-end)

^ we may have infra callback URIS or secrets that need to rotate

</head>
{/* suppressHydrationWarning is needed here as well since ThemeProvider modifies the class attribute */}
<body className={`${GeistSans.variable} ${GeistMono.variable} font-sans min-h-screen flex flex-col`} suppressHydrationWarning>
Expand Down
16 changes: 16 additions & 0 deletions components/frontend/src/app/projects/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -48,6 +50,14 @@ export default function ProjectsPage() {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [projectToDelete, setProjectToDelete] = useState<Project | null>(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('');
Expand Down Expand Up @@ -355,6 +365,12 @@ export default function ProjectsPage() {
open={showCreateDialog}
onOpenChange={setShowCreateDialog}
/>

{/* Onboarding wizard for first-time users */}
<WelcomeWizard
open={wizardOpen}
onDismiss={() => { setWizardOpen(false); dismissOnboarding(); }}
/>
</div>
</div>
);
Expand Down
236 changes: 15 additions & 221 deletions components/frontend/src/components/create-workspace-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -32,123 +25,23 @@ export function CreateWorkspaceDialog({
}: CreateWorkspaceDialogProps) {
const router = useRouter();
const createProjectMutation = useCreateProject();
const { isOpenShift, isLoading: clusterLoading } = useClusterInfo();
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState<CreateProjectRequest>({
name: "",
displayName: "",
description: "",
});

const [nameError, setNameError] = useState<string | null>(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)}`);
},
Expand All @@ -167,117 +60,18 @@ export function CreateWorkspaceDialog({
<DialogHeader className="space-y-3">
<DialogTitle>Create New Workspace</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>

<form onSubmit={handleSubmit} className="space-y-8 pt-2">
{/* Cluster info banner */}
{!clusterLoading && !isOpenShift && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Running on vanilla Kubernetes. Display name and description
fields are not available.
</AlertDescription>
</Alert>
)}

{/* Basic Information */}
<div className="space-y-6">

{/* OpenShift-only fields */}
{isOpenShift && (
<div className="space-y-2">
<Label htmlFor="displayName">Workspace Name *</Label>
<Input
id="displayName"
data-testid="workspace-display-name-input"
value={formData.displayName}
onChange={(e) => handleDisplayNameChange(e.target.value)}
placeholder="e.g. My Research Workspace"
maxLength={100}
/>
</div>
)}

{/* Vanilla Kubernetes name field */}
{!isOpenShift && (
<div className="space-y-2">
<Label htmlFor="name">Workspace Name *</Label>
<Input
id="name"
data-testid="workspace-slug-input"
value={formData.name}
onChange={(e) => {
const name = e.target.value;
setFormData((prev) => ({ ...prev, name }));
setNameError(validateProjectName(name));
}}
placeholder="my-research-workspace"
className={nameError ? "border-red-500" : ""}
/>
{nameError && <p className="text-sm text-red-600 dark:text-red-400">{nameError}</p>}
<p className="text-sm text-muted-foreground">
Lowercase alphanumeric with hyphens.
</p>
</div>
)}

{/* OpenShift-only description field */}
{isOpenShift && (
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) =>
setFormData((prev) => ({
...prev,
description: e.target.value,
}))
}
placeholder="Description of the workspace purpose and goals..."
maxLength={500}
rows={3}
/>
</div>
)}
</div>

{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-md dark:bg-red-950/50 dark:border-red-800">
<p className="text-red-700 dark:text-red-300">{error}</p>
</div>
)}

<DialogFooter className="pt-2">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={createProjectMutation.isPending}
>
Cancel
</Button>
<Button data-testid="create-workspace-submit"
type="submit"
disabled={createProjectMutation.isPending || !!nameError}
>
{createProjectMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Create Workspace
</>
)}
</Button>
</DialogFooter>
</form>
<WorkspaceForm
onSubmit={handleSubmit}
onCancel={handleClose}
isSubmitting={createProjectMutation.isPending}
error={error}
/>
</DialogContent>
</Dialog>
);
Expand Down
Loading
Loading