20 KiB
Phase 32: Multi-Step Onboarding Wizard - Research
Researched: 2026-04-01 Domain: React wizard UI — step navigation, summary screen, skip flow, chat handoff Confidence: HIGH
Summary
Phase 32 completes the onboarding wizard that Phases 30 and 31 built. The wizard already has 4 steps (hardware detection, mode selection, provider selection, root directory). This phase adds three things: (1) a summary screen that replaces or follows the root directory step to show the user what they configured, (2) a guaranteed skip path through every step, and (3) a "Go to chat" button on the summary screen that opens the chat panel and closes the wizard without waiting.
The skip mechanism already partially exists — ProviderSelectionStep has a "Skip for now" ghost button. What's missing is: (a) consistent skip affordance on steps 1 and 2, (b) a full summary step (step 5) that displays hardware tier, selected mode, and chosen provider, and (c) a one-click path from summary to chat using useChatPanel().setChatOpen(true).
No server-side changes are needed. Voice options (Whisper STT) are already wired server-side via POST /api/transcribe in chat-files.ts but there is no onboarding step for them. VOICE-03 is assigned to Phase 34, not Phase 32. Phase 32 should not introduce voice configuration UI — that is out of scope here.
Primary recommendation: Add a OnboardingSummaryStep component in ui/src/components/onboarding/, insert it as step 5 in NexusOnboardingWizard.tsx, add skip buttons to steps 1 and 2, and wire the summary's "Start chatting" CTA to setChatOpen(true) from useChatPanel.
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
All implementation choices are at Claude's discretion — discuss phase was skipped per user setting. Use ROADMAP phase goal, success criteria, and codebase conventions to guide decisions.
Claude's Discretion
All implementation choices.
Deferred Ideas (OUT OF SCOPE)
None — discuss phase skipped. </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| ONBD-04 | User can skip any onboarding step without blocking subsequent steps | Steps 1 and 2 have no skip button today; step 3 has "Skip for now" ghost button; pattern is established — replicate to all steps |
| ONBD-05 | User sees summary screen showing configured providers and agent-model pairings | New OnboardingSummaryStep component needed as step 5; data available in wizard state (hardwareInfo, selectedMode, puterToken/googleOAuthStateId/apiKeyData) |
| ONBD-06 | User can go from summary screen directly into chat with one click | useChatPanel().setChatOpen(true) + closeOnboarding() + navigate(/${company.issuePrefix}/dashboard) — but this requires company creation first |
| </phase_requirements> |
Standard Stack
Core
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| React | 18.x (project baseline) | Wizard component state | Already in use throughout |
| @tanstack/react-query | 5.x (project baseline) | Hardware info query | useHardwareInfo hook already uses it |
react-router-dom (via @/lib/router) |
project alias | Navigation after wizard completion | Already used in wizard |
Supporting
| Library | Version | Purpose | When to Use |
|---|---|---|---|
| lucide-react | project baseline | Icons in summary screen | All existing onboarding components use it |
@/components/ui/button |
project baseline | Consistent button styling | All CTA buttons in wizard use it |
cn from @/lib/utils |
project baseline | Conditional class merging | Used in all onboarding sub-components |
Installation: No new packages. All dependencies already installed.
Architecture Patterns
Recommended Project Structure
ui/src/components/
├── NexusOnboardingWizard.tsx # main wizard — add step 5 (summary), skip to steps 1+2
└── onboarding/
├── HardwareSummaryStep.tsx # existing (step 1 display)
├── ModeSelector.tsx # existing (step 2)
├── ProviderSelectionStep.tsx # existing (step 3) — already has skip
├── ApiKeyEntryForm.tsx # existing
├── GoogleOAuthButton.tsx # existing
├── PuterAuthButton.tsx # existing
└── OnboardingSummaryStep.tsx # NEW — step 5
Pattern 1: Step State Machine
What: Integer step state (1-4 today, will become 1-5). Each step renders conditionally on {step === N}. Navigation via setStep(N).
When to use: Already the established pattern — do not change to a different mechanism.
Example:
// From NexusOnboardingWizard.tsx (existing pattern)
const [step, setStep] = useState(1);
// ...
{step === 5 && (
<OnboardingSummaryStep
hardwareInfo={hardwareInfo}
selectedMode={selectedMode}
providerChosen={providerChosen}
onStartChat={handleStartChat}
onBack={() => setStep(4)}
/>
)}
Pattern 2: Skip Button (ghost variant)
What: Every step except the last (summary) must have a skip CTA. Current pattern from ProviderSelectionStep: <Button variant="ghost" onClick={onSkip}>Skip for now</Button>.
When to use: Steps 1, 2, and 4 need skip added. Step 3 already has it.
Example:
// Step 1 — hardware detection, add after Continue button:
<Button type="button" variant="ghost" onClick={() => setStep(2)} className="w-full">
Skip
</Button>
// Step 2 — mode selection, add after Back/Continue buttons:
<Button type="button" variant="ghost" onClick={() => setStep(3)} className="w-full">
Skip
</Button>
// Step 4 — root directory, add after Back button:
<Button type="button" variant="ghost" onClick={() => setStep(5)} className="w-full" disabled={loading}>
Skip to summary
</Button>
Pattern 3: Jump to Chat (ONBD-06)
What: On the summary screen, "Start chatting" CTA calls setChatOpen(true) then calls handleSubmit logic (or equivalent). The key insight: company creation must happen before navigating to chat. The summary step is shown after step 4 (root directory) where the user may or may not have entered a directory. The "Start chatting" button must trigger company creation if it hasn't happened yet.
When to use: Summary step's primary CTA.
Two design options:
Option A (simpler): Keep the existing handleSubmit on step 4, make "Get Started" button advance to step 5 (summary) instead of submitting, then summary's "Start chatting" actually submits. Company creation happens when the user clicks "Start chatting".
Option B: Move company creation to wizard close, make step 5 a pure display step, "Start chatting" closes wizard and opens chat.
Recommendation: Option A. The summary step is after all configuration is complete; when user clicks "Start chatting" on the summary, call the existing handleSubmit logic plus setChatOpen(true) afterward.
// In NexusOnboardingWizard.tsx
import { useChatPanel } from "../context/ChatPanelContext";
// ...
const { setChatOpen } = useChatPanel();
async function handleStartChat(e?: React.FormEvent) {
// Same company creation logic as handleSubmit
// After navigate(/${company.issuePrefix}/dashboard):
setChatOpen(true);
}
Pattern 4: OnboardingSummaryStep Component
What: A read-only summary card showing: hardware tier + memory, selected mode, provider name (or "None selected"). When to use: Step 5 of the wizard. Props:
interface OnboardingSummaryStepProps {
hardwareInfo: HardwareInfo | undefined;
selectedMode: NexusMode;
providerChosen: "puter" | "google" | "apikey" | null;
rootDir: string;
loading: boolean;
error: string | null;
onStartChat: () => void;
onBack: () => void;
}
Anti-Patterns to Avoid
- Changing the step integer type to a string enum: The existing
step: 1 | 2 | 3 | 4pattern is simple and consistent — keep it as1 | 2 | 3 | 4 | 5. - Moving company creation earlier (before summary): Company creation must stay in
handleSubmit— called on "Start chatting" click. Creating the company before the user finalizes breaks the established pattern from Phase 31. - Calling
setChatOpen(true)beforenavigate(): The chat panel is rendered insideLayout, which only renders after navigation to a company route. CallsetChatOpen(true)afternavigate(). - Adding voice step in Phase 32: VOICE-03 is Phase 34. Do not add a voice config step now.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Provider display name | Custom string map | Inline label constants | Simple enough for a const PROVIDER_LABELS in the summary component |
| Chat panel open state | Custom context/ref | useChatPanel().setChatOpen |
Already wired to localStorage persistence and Layout rendering |
| Hardware tier label | Custom detection | Read from hardwareInfo.hardwareTier |
Already computed by useHardwareInfo hook |
| Step validation | Custom state machine | Simple setStep(N) calls |
5 linear steps, no branching beyond skip |
Key insight: This phase is pure UI composition. All data is already in wizard state; no new APIs, hooks, or server routes are needed.
Runtime State Inventory
Not applicable. This is a greenfield UI-only change — no rename, refactor, or migration involved.
Common Pitfalls
Pitfall 1: Step Count Mismatch in UI
What goes wrong: The wizard shows "Step X of 4" in the header but there are now 5 steps.
Why it happens: <p className="text-xs text-muted-foreground text-center">Step {step} of 4</p> is hardcoded.
How to avoid: Change 4 to 5 in the step indicator string when adding step 5. Also suppress "Step 5 of 5" on the summary — show "Summary" or "Almost done" instead since it's not a data entry step.
Warning signs: QA screenshot shows "Step 5 of 4".
Pitfall 2: setChatOpen(true) Before Company Route Exists
What goes wrong: Chat panel tries to render in a context without a selectedCompanyId, showing an empty state or an error.
Why it happens: ChatPanel uses useChatPanel() and useCompany(). The company only exists after companiesApi.create() and setSelectedCompanyId().
How to avoid: Call setChatOpen(true) after navigate(\/${company.issuePrefix}/dashboard`)insidehandleStartChat`. React Router navigation is synchronous at the call site — Layout re-renders with the new company before the next paint.
Warning signs: Chat panel opens but shows no conversation list.
Pitfall 3: Summary Step Not Receiving rootDir Data
What goes wrong: Summary screen shows empty root directory field, even though user entered one.
Why it happens: rootDir state is in the wizard parent but not passed to OnboardingSummaryStep.
How to avoid: Pass rootDir as a prop. The component should display it in a font-mono text-sm span (matching the existing Input style).
Warning signs: TypeScript error on prop type mismatch, or blank field in summary.
Pitfall 4: Skip on Step 4 Skips Company Creation
What goes wrong: User skips the root directory step → summary appears → user clicks "Start chatting" → submit runs with empty rootDir → fails for claude_local adapter (which requires a cwd).
Why it happens: handleSubmit has if (defaultAdapter === "claude_local" && !rootDir.trim()) return; guard.
How to avoid: The submit logic already handles this correctly — it returns early without creating a company. The "Start chatting" button on the summary must be the same submit function. If defaultAdapter === "claude_local" and rootDir is empty, show the error on the summary screen. Alternatively, grey out "Start chatting" with a tooltip "Root directory required for Claude Code".
Warning signs: User clicks "Start chatting" and nothing happens (silent return in handleSubmit).
Pitfall 5: Provider Label for No-Provider Case
What goes wrong: Summary screen shows null or undefined when no provider was selected.
Why it happens: puterToken, googleOAuthStateId, and apiKeyData are all null when user skipped step 3.
How to avoid: Show "No provider selected — you can add one later in Settings" when all three are null.
Warning signs: Summary shows blank provider row or crashes on null access.
Code Examples
Reading Provider State for Summary Display
// Source: NexusOnboardingWizard.tsx (existing state)
// Determine which provider was configured
function deriveProviderLabel(
puterToken: string | null,
googleOAuthStateId: string | null,
apiKeyData: { provider: string; apiKey: string } | null,
): string {
if (puterToken) return "Puter (free, zero-config)";
if (googleOAuthStateId) return "Google Gemini (free tier)";
if (apiKeyData) return `API key — ${apiKeyData.provider}`;
return "None selected";
}
Opening Chat After Wizard Completion
// Source: ChatPanelContext.tsx — setChatOpen persists to localStorage
const { setChatOpen } = useChatPanel();
// Inside handleStartChat, after navigate():
navigate(`/${company.issuePrefix}/dashboard`);
setChatOpen(true);
closeOnboarding();
Summary Step Skeleton
// ui/src/components/onboarding/OnboardingSummaryStep.tsx
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { HardwareInfo } from "@/api/hardware";
import type { NexusMode } from "@/api/hardware";
interface OnboardingSummaryStepProps {
hardwareInfo: HardwareInfo | undefined;
selectedMode: NexusMode;
providerLabel: string;
rootDir: string;
loading: boolean;
error: string | null;
onStartChat: () => void;
onBack: () => void;
}
export function OnboardingSummaryStep({
hardwareInfo, selectedMode, providerLabel, rootDir, loading, error, onStartChat, onBack,
}: OnboardingSummaryStepProps) {
const modeLabels: Record<NexusMode, string> = {
personal_ai: "Personal AI Assistant",
project_builder: "Project Builder",
both: "Both (recommended)",
};
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2 rounded-lg border border-border p-4 text-sm">
<SummaryRow label="Hardware" value={hardwareInfo?.hardwareTier ?? "Unknown"} />
<SummaryRow label="Mode" value={modeLabels[selectedMode]} />
<SummaryRow label="Provider" value={providerLabel} />
{rootDir && <SummaryRow label="Root directory" value={rootDir} mono />}
</div>
{error && (
<p className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">{error}</p>
)}
<Button type="button" onClick={onStartChat} disabled={loading} className="w-full">
{loading ? "Setting up…" : "Start chatting"}
</Button>
<Button type="button" variant="ghost" onClick={onBack} className="w-full" disabled={loading}>
Back
</Button>
</div>
);
}
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
| 4-step wizard (hw, mode, provider, rootDir) | 5-step wizard adding summary | Phase 32 | No breaking changes — just append step 5 |
| No skip on steps 1 and 2 | Skip button on all steps | Phase 32 | Users who skip all still need one valid agent — see PROJECT.md constraint |
Key constraint from PROJECT.md (established in roadmap):
Skip-all minimum valid state: one working agent with a valid provider must be created when user skips all steps.
This means when the user skips everything and clicks "Start chatting" on the summary: the wizard must still call handleSubmit (which creates the company + agents). The default adapter (claude_local or hermes_local) and optional rootDir must still produce a valid state. For hermes_local this is fine (rootDir optional). For claude_local with no rootDir — the submit guard returns early. The UX must communicate this: disable "Start chatting" or show inline error on the summary if claude_local adapter is selected and rootDir is empty.
Environment Availability
Step 2.6: SKIPPED — this phase is UI-only React changes. No new external dependencies.
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework | Vitest + React Testing Library (project standard) |
| Config file | ui/vitest.config.ts |
| Quick run command | pnpm vitest run ui/src/components/onboarding/ |
| Full suite command | pnpm test:run |
Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| ONBD-04 | Skip button present on all steps | unit | pnpm vitest run ui/src/components/NexusOnboardingWizard.test.tsx |
❌ Wave 0 |
| ONBD-05 | Summary screen renders hardware/mode/provider data | unit | pnpm vitest run ui/src/components/onboarding/OnboardingSummaryStep.test.tsx |
❌ Wave 0 |
| ONBD-06 | "Start chatting" calls setChatOpen(true) and navigate | unit | pnpm vitest run ui/src/components/NexusOnboardingWizard.test.tsx |
❌ Wave 0 |
Sampling Rate
- Per task commit:
pnpm vitest run ui/src/components/onboarding/ - Per wave merge:
pnpm test:run - Phase gate: Full suite green before
/gsd:verify-work
Wave 0 Gaps
ui/src/components/onboarding/OnboardingSummaryStep.test.tsx— covers ONBD-05ui/src/components/NexusOnboardingWizard.test.tsx— covers ONBD-04, ONBD-06
Open Questions
-
Should step 4 (root directory) survive or be merged into summary?
- What we know: Step 4 is the only form input step. Summary (step 5) is read-only except for the submit CTA.
- What's unclear: Whether the root directory input belongs on the summary screen or stays as its own step.
- Recommendation: Keep step 4 as a separate form step; summary is step 5 (pure read-only). This is less disruptive to existing code.
-
"Start chatting" on summary — does it also navigate or only open chat panel?
- What we know: Current wizard navigates to
/${company.issuePrefix}/dashboardon completion. - What's unclear: The requirement says "go directly into chat" — does this mean open the chat panel on dashboard, or navigate to a chat-specific route?
- Recommendation: Navigate to dashboard +
setChatOpen(true). The chat panel slides in from the right on all company pages. No separate chat route exists.
- What we know: Current wizard navigates to
-
Step indicator label for summary step
- What we know: Current indicator is "Step N of 4" — hardcoded.
- Recommendation: Show "Summary" instead of "Step 5 of 5" on step 5 to signal it's a review step, not a data entry step.
Sources
Primary (HIGH confidence)
- Direct codebase inspection:
ui/src/components/NexusOnboardingWizard.tsx— full wizard implementation - Direct codebase inspection:
ui/src/components/onboarding/*.tsx— all 6 sub-components - Direct codebase inspection:
ui/src/context/ChatPanelContext.tsx— setChatOpen API - Direct codebase inspection:
server/src/routes/chat-files.ts— Whisper transcription (Phase 34 only) - Direct codebase inspection:
.planning/STATE.md— Project constraints (skip-all minimum valid state) - Direct codebase inspection:
.planning/REQUIREMENTS.md— ONBD-04/05/06 exact wording
Secondary (MEDIUM confidence)
- None needed — all required information in codebase.
Tertiary (LOW confidence)
- None.
Metadata
Confidence breakdown:
- Standard stack: HIGH — all existing code confirmed
- Architecture: HIGH — wizard pattern is established, summary step is additive
- Pitfalls: HIGH — derived from direct reading of existing component code and constraint documentation
Research date: 2026-04-01 Valid until: 2026-05-01 (stable React codebase, no moving dependencies)