feat(issue-17): add onboarding wizard for new users (#26)

* feat(issue-17): add onboarding wizard for new users

Adds a step-based onboarding wizard that guides new users through their
first Claw3D setup: welcome, prerequisites, gateway connection, agent
discovery, and a completion screen.

Architecture:

src/features/onboarding/ (new feature module):
  - types.ts: Step definitions, navigation helpers (getNextStep/getPrevStep)
  - useOnboardingState.ts: localStorage-backed persistence hook
  - index.ts: Barrel exports for clean imports
  - components/OnboardingWizard.tsx: Main wizard container with step
    navigation, progress bar, and modal overlay
  - components/WelcomeStep.tsx: Feature highlights grid
  - components/PrerequisitesStep.tsx: Checklist with links/commands
  - components/ConnectStep.tsx: Compact gateway connection form
  - components/AgentsStep.tsx: Agent discovery feedback
  - components/CompleteStep.tsx: Final screen with CTA

Design decisions:
  - Modular step system: new steps can be added by extending
    OnboardingStepId and registering a component in the switch
  - localStorage persistence: wizard shows once per browser, resettable
    from settings (future: wire into Studio settings API)
  - Connect step gates forward navigation: users cannot skip connection
  - Follows Claw3D conventions: feature-first module, no shared state
    pollution, Tailwind utility classes, lucide-react icons
  - Does NOT modify existing routes or components — zero-risk integration
    (parent wiring left to maintainer preference)

Integration guide (for maintainer):
  1. Import { OnboardingWizard, useOnboardingState } from the module
  2. Add useOnboardingState() to the root layout or agents page
  3. Render <OnboardingWizard /> when showOnboarding is true
  4. Pass gateway connection props from existing store

tests/unit/onboardingTypes.test.ts (13 tests):
  - Step structure validation, navigation helpers, ordering

tests/unit/onboardingState.test.ts (5 tests):
  - localStorage persistence, show/hide/reset lifecycle

Addresses #17

* fix(onboarding): wire wizard launch into office UI

Mount the onboarding wizard in OfficeScreen and add a settings action that can re-open it so the new-user flow is reachable and testable.

Made-with: Cursor

---------

Co-authored-by: Neo <neo@openclaw.ai>
Co-authored-by: iamlukethedev <lucas.guilherme@smartwayslfl.com>
This commit is contained in:
robotica4us-collab
2026-03-21 17:37:54 -05:00
committed by GitHub
parent ac30f71db0
commit c2cbdeec44
14 changed files with 1001 additions and 0 deletions
@@ -0,0 +1,88 @@
/**
* AgentsStep — Shows discovered agents after gateway connection.
*/
import { Bot, Users, WifiOff } from "lucide-react";
export type AgentsStepProps = {
agentCount: number;
connected: boolean;
};
export const AgentsStep = ({ agentCount, connected }: AgentsStepProps) => {
if (!connected) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-8">
<WifiOff className="h-8 w-8 text-white/30" />
<p className="text-sm text-white/60">
Connect to your gateway first to discover agents.
</p>
</div>
);
}
if (agentCount === 0) {
return (
<div className="space-y-4">
<div className="flex flex-col items-center justify-center gap-3 py-6">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-white/5">
<Bot className="h-6 w-6 text-white/40" />
</div>
<p className="text-sm font-medium text-white">No agents found</p>
<p className="max-w-xs text-center text-xs text-white/55">
Your gateway is connected, but no agents are configured yet.
You can create agents from the Claw3D fleet sidebar after
completing this wizard.
</p>
</div>
<div className="rounded-lg border border-white/8 bg-white/[0.02] px-4 py-3">
<p className="text-xs font-medium text-white/80">Quick start:</p>
<ol className="mt-2 space-y-1.5 text-[11px] text-white/55">
<li>1. Click the + button in the fleet sidebar</li>
<li>2. Choose a name and model for your agent</li>
<li>3. Configure skills and personality</li>
<li>4. Watch your agent appear at their desk!</li>
</ol>
</div>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-lg border border-emerald-500/20 bg-emerald-500/5 px-4 py-3">
<Users className="h-5 w-5 text-emerald-400" />
<div>
<p className="text-sm font-semibold text-white">
{agentCount} agent{agentCount !== 1 ? "s" : ""} discovered
</p>
<p className="text-[11px] text-white/55">
Your AI team is ready and waiting in the office.
</p>
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-white/70">
What you can do with agents:
</p>
<div className="grid grid-cols-2 gap-2">
{[
{ label: "Chat", desc: "Send messages and get responses" },
{ label: "Approve", desc: "Review and approve exec commands" },
{ label: "Configure", desc: "Edit brain files and settings" },
{ label: "Monitor", desc: "Watch runtime activity in real time" },
].map(({ label, desc }) => (
<div
key={label}
className="rounded-md border border-white/5 bg-white/[0.02] px-3 py-2"
>
<p className="text-[11px] font-semibold text-white">{label}</p>
<p className="text-[10px] text-white/45">{desc}</p>
</div>
))}
</div>
</div>
</div>
);
};
@@ -0,0 +1,38 @@
/**
* CompleteStep — Final wizard screen before entering the office.
*/
import { Building2, Rocket } from "lucide-react";
export const CompleteStep = () => (
<div className="flex flex-col items-center justify-center gap-5 py-4">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/15">
<Rocket className="h-7 w-7 text-emerald-400" />
</div>
<div className="space-y-2 text-center">
<p className="text-base font-semibold text-white">
Welcome to your AI office
</p>
<p className="max-w-sm text-sm text-white/60">
Your gateway is connected and your agents are ready. Step inside and
explore the 3D workspace where your AI team operates.
</p>
</div>
<div className="w-full max-w-xs space-y-2">
<div className="flex items-center gap-2.5 rounded-lg border border-white/8 bg-white/[0.03] px-3.5 py-2.5">
<Building2 className="h-4 w-4 shrink-0 text-emerald-400" />
<div>
<p className="text-xs font-medium text-white">Explore the Office</p>
<p className="text-[10px] text-white/45">
Navigate rooms, watch agents, and interact
</p>
</div>
</div>
</div>
<p className="text-[11px] text-white/35">
You can always re-run onboarding from Studio settings.
</p>
</div>
);
@@ -0,0 +1,139 @@
/**
* ConnectStep — Gateway connection form within the onboarding wizard.
*
* Reuses the same fields as GatewayConnectScreen but in a more compact
* layout suited for the wizard modal.
*/
import { useState } from "react";
import { CheckCircle2, Eye, EyeOff, Loader2, Wifi, WifiOff } from "lucide-react";
export type ConnectStepProps = {
gatewayUrl: string;
token: string;
onGatewayUrlChange: (value: string) => void;
onTokenChange: (value: string) => void;
onConnect: () => void;
connected: boolean;
connecting: boolean;
error: string | null;
};
export const ConnectStep = ({
gatewayUrl,
token,
onGatewayUrlChange,
onTokenChange,
onConnect,
connected,
connecting,
error,
}: ConnectStepProps) => {
const [showToken, setShowToken] = useState(false);
if (connected) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-8">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-500/20">
<CheckCircle2 className="h-6 w-6 text-emerald-400" />
</div>
<p className="text-sm font-semibold text-white">Connected!</p>
<p className="text-xs text-white/60">
Your gateway is live. Click Next to continue.
</p>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2 rounded-lg border border-white/8 bg-white/[0.02] px-3 py-2.5">
<WifiOff className="h-4 w-4 text-white/40" />
<p className="text-xs text-white/60">Not connected</p>
</div>
<div className="space-y-3">
<label className="flex flex-col gap-1.5">
<span className="text-[11px] font-medium text-white/80">
Gateway URL
</span>
<input
className="h-9 rounded-md border border-white/10 bg-white/5 px-3 font-mono text-sm text-white outline-none placeholder:text-white/30 focus:border-emerald-500/50"
type="text"
value={gatewayUrl}
onChange={(e) => onGatewayUrlChange(e.target.value)}
placeholder="ws://localhost:18789 or wss://your-host"
spellCheck={false}
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-[11px] font-medium text-white/80">
Gateway Token
</span>
<div className="relative">
<input
className="h-9 w-full rounded-md border border-white/10 bg-white/5 px-3 pr-9 font-mono text-sm text-white outline-none placeholder:text-white/30 focus:border-emerald-500/50"
type={showToken ? "text" : "password"}
value={token}
onChange={(e) => onTokenChange(e.target.value)}
placeholder="your-gateway-token"
spellCheck={false}
/>
<button
type="button"
className="absolute inset-y-0 right-1 my-auto flex h-7 w-7 items-center justify-center rounded text-white/50 hover:text-white"
onClick={() => setShowToken((prev) => !prev)}
aria-label={showToken ? "Hide token" : "Show token"}
>
{showToken ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</button>
</div>
</label>
<button
type="button"
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-md bg-emerald-600 px-4 text-xs font-semibold text-white transition-colors hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
onClick={onConnect}
disabled={connecting || !gatewayUrl.trim()}
>
{connecting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Connecting
</>
) : (
<>
<Wifi className="h-3.5 w-3.5" />
Connect
</>
)}
</button>
</div>
{error ? (
<p className="rounded-md bg-red-500/10 px-3 py-2 text-xs text-red-400">
{error}
</p>
) : null}
<div className="space-y-1.5 text-[11px] text-white/40">
<p>
<strong className="text-white/60">Local?</strong> Use{" "}
<code className="text-white/50">ws://localhost:18789</code>
</p>
<p>
<strong className="text-white/60">Tailscale?</strong> Use{" "}
<code className="text-white/50">wss://your-host.ts.net</code>
</p>
<p>
<strong className="text-white/60">SSH tunnel?</strong> Forward port
18789 first, then use localhost.
</p>
</div>
</div>
);
};
@@ -0,0 +1,213 @@
/**
* OnboardingWizard — Step-based onboarding flow for new Claw3D users.
*
* Renders a modal overlay with step navigation, progress indicator,
* and content slots for each onboarding phase. Designed to be mounted
* at the app root and dismissed once complete or skipped.
*/
import { useCallback, useMemo, useState } from "react";
import { ArrowLeft, ArrowRight, X } from "lucide-react";
import {
getNextStep,
getPrevStep,
getStepIndex,
ONBOARDING_STEPS,
type OnboardingStepId,
} from "@/features/onboarding/types";
import { WelcomeStep } from "@/features/onboarding/components/WelcomeStep";
import { PrerequisitesStep } from "@/features/onboarding/components/PrerequisitesStep";
import { ConnectStep } from "@/features/onboarding/components/ConnectStep";
import { AgentsStep } from "@/features/onboarding/components/AgentsStep";
import { CompleteStep } from "@/features/onboarding/components/CompleteStep";
export type OnboardingWizardProps = {
/** Whether the gateway is currently connected. */
gatewayConnected: boolean;
/** Number of agents discovered. */
agentCount: number;
/** Gateway URL (for the connect step). */
gatewayUrl: string;
/** Gateway token (for the connect step). */
token: string;
/** Callbacks for the connect step. */
onGatewayUrlChange: (value: string) => void;
onTokenChange: (value: string) => void;
onConnect: () => void;
/** Called when the user finishes or dismisses the wizard. */
onComplete: () => void;
/** Connection error message, if any. */
connectionError: string | null;
/** Whether we're currently connecting. */
connecting: boolean;
};
export const OnboardingWizard = ({
gatewayConnected,
agentCount,
gatewayUrl,
token,
onGatewayUrlChange,
onTokenChange,
onConnect,
onComplete,
connectionError,
connecting,
}: OnboardingWizardProps) => {
const [currentStep, setCurrentStep] = useState<OnboardingStepId>("welcome");
const [completedSteps, setCompletedSteps] = useState<Set<OnboardingStepId>>(
new Set(),
);
const stepIndex = useMemo(() => getStepIndex(currentStep), [currentStep]);
const currentStepDef = ONBOARDING_STEPS[stepIndex];
const totalSteps = ONBOARDING_STEPS.length;
const markComplete = useCallback(
(stepId: OnboardingStepId) => {
setCompletedSteps((prev) => {
const next = new Set(prev);
next.add(stepId);
return next;
});
},
[],
);
const goNext = useCallback(() => {
markComplete(currentStep);
const next = getNextStep(currentStep);
if (next) {
setCurrentStep(next);
} else {
onComplete();
}
}, [currentStep, markComplete, onComplete]);
const goPrev = useCallback(() => {
const prev = getPrevStep(currentStep);
if (prev) setCurrentStep(prev);
}, [currentStep]);
const canGoNext = useMemo(() => {
// Connect step requires gateway connection before proceeding
if (currentStep === "connect" && !gatewayConnected) return false;
return true;
}, [currentStep, gatewayConnected]);
const renderStepContent = () => {
switch (currentStep) {
case "welcome":
return <WelcomeStep />;
case "prerequisites":
return <PrerequisitesStep />;
case "connect":
return (
<ConnectStep
gatewayUrl={gatewayUrl}
token={token}
onGatewayUrlChange={onGatewayUrlChange}
onTokenChange={onTokenChange}
onConnect={onConnect}
connected={gatewayConnected}
connecting={connecting}
error={connectionError}
/>
);
case "agents":
return <AgentsStep agentCount={agentCount} connected={gatewayConnected} />;
case "complete":
return <CompleteStep />;
default:
return null;
}
};
return (
<div className="fixed inset-0 z-[100000] flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div className="relative mx-4 flex w-full max-w-[560px] flex-col overflow-hidden rounded-xl border border-white/10 bg-[#0d1117] shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-white/10 px-6 py-4">
<div>
<h2 className="text-lg font-semibold text-white">
{currentStepDef?.title ?? "Onboarding"}
</h2>
<p className="mt-0.5 text-xs text-white/60">
{currentStepDef?.description}
</p>
</div>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-md text-white/50 transition-colors hover:bg-white/10 hover:text-white"
onClick={onComplete}
aria-label="Close onboarding"
title="Skip onboarding"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Progress bar */}
<div className="flex gap-1.5 px-6 pt-4">
{ONBOARDING_STEPS.map((step, idx) => (
<div
key={step.id}
className={`h-1 flex-1 rounded-full transition-colors ${
idx <= stepIndex
? "bg-emerald-500"
: completedSteps.has(step.id)
? "bg-emerald-500/40"
: "bg-white/10"
}`}
/>
))}
</div>
{/* Step content */}
<div className="min-h-[280px] px-6 py-5">{renderStepContent()}</div>
{/* Footer navigation */}
<div className="flex items-center justify-between border-t border-white/10 px-6 py-4">
<div>
{stepIndex > 0 ? (
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-md px-3 py-2 text-xs font-medium text-white/70 transition-colors hover:bg-white/10 hover:text-white"
onClick={goPrev}
>
<ArrowLeft className="h-3.5 w-3.5" />
Back
</button>
) : null}
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-white/40">
{stepIndex + 1} / {totalSteps}
</span>
{currentStep === "complete" ? (
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-md bg-emerald-600 px-4 py-2 text-xs font-semibold text-white transition-colors hover:bg-emerald-500"
onClick={onComplete}
>
Enter Office
</button>
) : (
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-md bg-white/10 px-4 py-2 text-xs font-semibold text-white transition-colors hover:bg-white/20 disabled:cursor-not-allowed disabled:opacity-40"
onClick={goNext}
disabled={!canGoNext}
>
{currentStep === "connect" && !gatewayConnected
? "Connect first"
: "Next"}
<ArrowRight className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,90 @@
/**
* PrerequisitesStep — Tells users what they need before connecting.
*/
import { CheckCircle2, ExternalLink } from "lucide-react";
const prerequisites = [
{
label: "OpenClaw installed",
detail: "Install via npm, pnpm, or from source",
link: "https://docs.openclaw.ai",
linkLabel: "Installation docs",
},
{
label: "Gateway running",
detail: "Start with: openclaw gateway start",
command: "openclaw gateway start",
},
{
label: "Gateway URL and token",
detail: "Found in ~/.openclaw/openclaw.json or your remote setup",
},
{
label: "Node.js 20+",
detail: "Required for running Claw3D locally",
link: "https://nodejs.org",
linkLabel: "Download Node.js",
},
] as const;
export const PrerequisitesStep = () => (
<div className="space-y-4">
<p className="text-sm text-white/70">
Make sure you have these ready before connecting. If you already have
OpenClaw running, you can skip this step.
</p>
<div className="space-y-2.5">
{prerequisites.map(({ label, detail, ...rest }) => (
<div
key={label}
className="flex gap-3 rounded-lg border border-white/8 bg-white/[0.02] px-3.5 py-3"
>
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-white/30" />
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold text-white">{label}</p>
<p className="mt-0.5 text-[11px] text-white/55">{detail}</p>
{"command" in rest ? (
<code className="mt-1.5 block rounded bg-black/40 px-2 py-1 font-mono text-[11px] text-emerald-400">
{rest.command}
</code>
) : null}
{"link" in rest && rest.link ? (
<a
href={rest.link}
target="_blank"
rel="noopener noreferrer"
className="mt-1.5 inline-flex items-center gap-1 text-[11px] text-emerald-400 hover:text-emerald-300"
>
{rest.linkLabel ?? "Learn more"}
<ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
</div>
))}
</div>
<p className="text-[11px] text-white/40">
Need help? Check{" "}
<a
href="https://docs.openclaw.ai"
target="_blank"
rel="noopener noreferrer"
className="text-emerald-400/70 hover:text-emerald-300"
>
docs.openclaw.ai
</a>{" "}
or{" "}
<a
href="https://discord.com/invite/clawd"
target="_blank"
rel="noopener noreferrer"
className="text-emerald-400/70 hover:text-emerald-300"
>
join Discord
</a>
.
</p>
</div>
);
@@ -0,0 +1,61 @@
/**
* WelcomeStep — First onboarding screen introducing Claw3D.
*/
import { Building2, Eye, MessageSquare, Users } from "lucide-react";
const features = [
{
icon: Eye,
title: "Watch agents work",
description: "See your AI agents in real time in a shared 3D office",
},
{
icon: Users,
title: "Manage your fleet",
description: "Create, configure, and monitor agents from one place",
},
{
icon: MessageSquare,
title: "Chat and approve",
description: "Talk to agents, approve exec commands, review their work",
},
{
icon: Building2,
title: "Build your office",
description: "Customize rooms, desks, and the whole office layout",
},
] as const;
export const WelcomeStep = () => (
<div className="space-y-5">
<div className="space-y-2">
<p className="text-sm leading-relaxed text-white/80">
Claw3D turns your AI automation into a{" "}
<span className="font-medium text-white">visual workplace</span> an
office where your OpenClaw agents collaborate, code, test, and execute
tasks in a shared 3D environment.
</p>
<p className="text-sm text-white/60">
This wizard will help you connect to your OpenClaw gateway and get
started in about two minutes.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
{features.map(({ icon: Icon, title, description }) => (
<div
key={title}
className="rounded-lg border border-white/8 bg-white/[0.03] px-3.5 py-3"
>
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 shrink-0 text-emerald-400" />
<span className="text-xs font-semibold text-white">{title}</span>
</div>
<p className="mt-1.5 text-[11px] leading-snug text-white/55">
{description}
</p>
</div>
))}
</div>
</div>
);