diff --git a/src/features/office/components/panels/SettingsPanel.tsx b/src/features/office/components/panels/SettingsPanel.tsx index ff1f07b..6e88180 100644 --- a/src/features/office/components/panels/SettingsPanel.tsx +++ b/src/features/office/components/panels/SettingsPanel.tsx @@ -6,6 +6,7 @@ type SettingsPanelProps = { gatewayStatus?: string; gatewayUrl?: string; onGatewayDisconnect?: () => void; + onOpenOnboarding?: () => void; officeTitle: string; officeTitleLoaded: boolean; onOfficeTitleChange: (title: string) => void; @@ -23,6 +24,7 @@ export function SettingsPanel({ gatewayStatus, gatewayUrl, onGatewayDisconnect, + onOpenOnboarding, officeTitle, officeTitleLoaded, onOfficeTitleChange, @@ -97,6 +99,23 @@ export function SettingsPanel({ +
+
+
+
Onboarding
+
+ Re-open the onboarding wizard to test the new-user flow. +
+
+ +
+
+
+ + + +
+ + {error ? ( +

+ {error} +

+ ) : null} + +
+

+ Local? Use{" "} + ws://localhost:18789 +

+

+ Tailscale? Use{" "} + wss://your-host.ts.net +

+

+ SSH tunnel? Forward port + 18789 first, then use localhost. +

+
+ + ); +}; diff --git a/src/features/onboarding/components/OnboardingWizard.tsx b/src/features/onboarding/components/OnboardingWizard.tsx new file mode 100644 index 0000000..e2dbba5 --- /dev/null +++ b/src/features/onboarding/components/OnboardingWizard.tsx @@ -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("welcome"); + const [completedSteps, setCompletedSteps] = useState>( + 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 ; + case "prerequisites": + return ; + case "connect": + return ( + + ); + case "agents": + return ; + case "complete": + return ; + default: + return null; + } + }; + + return ( +
+
+ {/* Header */} +
+
+

+ {currentStepDef?.title ?? "Onboarding"} +

+

+ {currentStepDef?.description} +

+
+ +
+ + {/* Progress bar */} +
+ {ONBOARDING_STEPS.map((step, idx) => ( +
+ ))} +
+ + {/* Step content */} +
{renderStepContent()}
+ + {/* Footer navigation */} +
+
+ {stepIndex > 0 ? ( + + ) : null} +
+
+ + {stepIndex + 1} / {totalSteps} + + {currentStep === "complete" ? ( + + ) : ( + + )} +
+
+
+
+ ); +}; diff --git a/src/features/onboarding/components/PrerequisitesStep.tsx b/src/features/onboarding/components/PrerequisitesStep.tsx new file mode 100644 index 0000000..55dd2fe --- /dev/null +++ b/src/features/onboarding/components/PrerequisitesStep.tsx @@ -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 = () => ( +
+

+ Make sure you have these ready before connecting. If you already have + OpenClaw running, you can skip this step. +

+ +
+ {prerequisites.map(({ label, detail, ...rest }) => ( +
+ +
+

{label}

+

{detail}

+ {"command" in rest ? ( + + {rest.command} + + ) : null} + {"link" in rest && rest.link ? ( + + {rest.linkLabel ?? "Learn more"} + + + ) : null} +
+
+ ))} +
+ +

+ Need help? Check{" "} + + docs.openclaw.ai + {" "} + or{" "} + + join Discord + + . +

+
+); diff --git a/src/features/onboarding/components/WelcomeStep.tsx b/src/features/onboarding/components/WelcomeStep.tsx new file mode 100644 index 0000000..0710f45 --- /dev/null +++ b/src/features/onboarding/components/WelcomeStep.tsx @@ -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 = () => ( +
+
+

+ Claw3D turns your AI automation into a{" "} + visual workplace — an + office where your OpenClaw agents collaborate, code, test, and execute + tasks in a shared 3D environment. +

+

+ This wizard will help you connect to your OpenClaw gateway and get + started in about two minutes. +

+
+ +
+ {features.map(({ icon: Icon, title, description }) => ( +
+
+ + {title} +
+

+ {description} +

+
+ ))} +
+
+); diff --git a/src/features/onboarding/index.ts b/src/features/onboarding/index.ts new file mode 100644 index 0000000..347a18d --- /dev/null +++ b/src/features/onboarding/index.ts @@ -0,0 +1,15 @@ +/** + * Onboarding feature module. + * + * Usage: + * import { OnboardingWizard, useOnboardingState } from "@/features/onboarding"; + */ +export { OnboardingWizard } from "@/features/onboarding/components/OnboardingWizard"; +export type { OnboardingWizardProps } from "@/features/onboarding/components/OnboardingWizard"; +export { useOnboardingState } from "@/features/onboarding/useOnboardingState"; +export type { OnboardingStateReturn } from "@/features/onboarding/useOnboardingState"; +export { + ONBOARDING_STEPS, + type OnboardingStepId, + type OnboardingStep, +} from "@/features/onboarding/types"; diff --git a/src/features/onboarding/types.ts b/src/features/onboarding/types.ts new file mode 100644 index 0000000..da0d317 --- /dev/null +++ b/src/features/onboarding/types.ts @@ -0,0 +1,85 @@ +/** + * Onboarding wizard types. + * + * The wizard is step-based and extensible: new steps can be added by + * extending `OnboardingStepId` and registering a component in the + * step registry. + */ + +export type OnboardingStepId = + | "welcome" + | "prerequisites" + | "connect" + | "agents" + | "complete"; + +export type OnboardingStep = { + id: OnboardingStepId; + title: string; + description: string; + /** Whether the step can be skipped. */ + skippable: boolean; +}; + +export type OnboardingState = { + currentStep: OnboardingStepId; + completedSteps: Set; + /** Whether the user has dismissed the wizard entirely. */ + dismissed: boolean; + /** Gateway connection state passed from the parent. */ + gatewayConnected: boolean; + /** Number of agents discovered after connection. */ + agentCount: number; +}; + +export const ONBOARDING_STEPS: OnboardingStep[] = [ + { + id: "welcome", + title: "Welcome to Claw3D", + description: "Your AI office in 3D", + skippable: false, + }, + { + id: "prerequisites", + title: "Before You Start", + description: "What you'll need", + skippable: true, + }, + { + id: "connect", + title: "Connect Your Gateway", + description: "Link to your OpenClaw instance", + skippable: false, + }, + { + id: "agents", + title: "Your Agents", + description: "Meet your AI team", + skippable: true, + }, + { + id: "complete", + title: "You're All Set", + description: "Start exploring", + skippable: false, + }, +]; + +export const getStepIndex = (stepId: OnboardingStepId): number => + ONBOARDING_STEPS.findIndex((s) => s.id === stepId); + +export const getNextStep = ( + currentId: OnboardingStepId, +): OnboardingStepId | null => { + const idx = getStepIndex(currentId); + if (idx < 0 || idx >= ONBOARDING_STEPS.length - 1) return null; + return ONBOARDING_STEPS[idx + 1].id; +}; + +export const getPrevStep = ( + currentId: OnboardingStepId, +): OnboardingStepId | null => { + const idx = getStepIndex(currentId); + if (idx <= 0) return null; + return ONBOARDING_STEPS[idx - 1].id; +}; diff --git a/src/features/onboarding/useOnboardingState.ts b/src/features/onboarding/useOnboardingState.ts new file mode 100644 index 0000000..e05d219 --- /dev/null +++ b/src/features/onboarding/useOnboardingState.ts @@ -0,0 +1,60 @@ +/** + * useOnboardingState — Tracks whether onboarding has been completed. + * + * Uses localStorage so the wizard only shows once per browser. + * The key is scoped to the Claw3D app to avoid collisions. + */ +import { useCallback, useState } from "react"; + +const STORAGE_KEY = "claw3d:onboarding:completed"; + +const readCompleted = (): boolean => { + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem(STORAGE_KEY) === "true"; + } catch { + return false; + } +}; + +const writeCompleted = (value: boolean): void => { + if (typeof window === "undefined") return; + try { + if (value) { + window.localStorage.setItem(STORAGE_KEY, "true"); + } else { + window.localStorage.removeItem(STORAGE_KEY); + } + } catch { + // Storage might be unavailable in some environments. + } +}; + +export type OnboardingStateReturn = { + /** Whether the wizard should be shown. */ + showOnboarding: boolean; + /** Mark onboarding as complete (hides the wizard). */ + completeOnboarding: () => void; + /** Reset onboarding (shows the wizard again). */ + resetOnboarding: () => void; +}; + +export const useOnboardingState = (): OnboardingStateReturn => { + const [completed, setCompleted] = useState(readCompleted); + + const completeOnboarding = useCallback(() => { + setCompleted(true); + writeCompleted(true); + }, []); + + const resetOnboarding = useCallback(() => { + setCompleted(false); + writeCompleted(false); + }, []); + + return { + showOnboarding: !completed, + completeOnboarding, + resetOnboarding, + }; +}; diff --git a/src/features/retro-office/RetroOffice3D.tsx b/src/features/retro-office/RetroOffice3D.tsx index 7939d1d..aa199e4 100644 --- a/src/features/retro-office/RetroOffice3D.tsx +++ b/src/features/retro-office/RetroOffice3D.tsx @@ -1892,6 +1892,7 @@ export function RetroOffice3D({ onVoiceRepliesSpeedChange, onVoiceRepliesPreview, onGatewayDisconnect, + onOpenOnboarding, atmAnalytics = null, feedEvents = [], gatewayStatus = "disconnected", @@ -1949,6 +1950,7 @@ export function RetroOffice3D({ onVoiceRepliesSpeedChange?: (speed: number) => void; onVoiceRepliesPreview?: (voiceId: string | null, voiceName: string) => void; onGatewayDisconnect?: () => void; + onOpenOnboarding?: () => void; atmAnalytics?: OfficeUsageAnalyticsParams | null; feedEvents?: { id: string; @@ -6049,6 +6051,10 @@ export function RetroOffice3D({ onGatewayDisconnect?.(); setSettingsModalOpen(false); }} + onOpenOnboarding={() => { + onOpenOnboarding?.(); + setSettingsModalOpen(false); + }} officeTitle={officeTitle} officeTitleLoaded={officeTitleLoaded} onOfficeTitleChange={(title) => onOfficeTitleChange?.(title)} diff --git a/tests/unit/onboardingState.test.ts b/tests/unit/onboardingState.test.ts new file mode 100644 index 0000000..6640b1d --- /dev/null +++ b/tests/unit/onboardingState.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { useOnboardingState } from "@/features/onboarding/useOnboardingState"; + +describe("useOnboardingState", () => { + afterEach(() => { + // Clean up localStorage between tests + try { + window.localStorage.removeItem("claw3d:onboarding:completed"); + } catch { + // noop + } + }); + + it("shows onboarding by default when localStorage is empty", () => { + const { result } = renderHook(() => useOnboardingState()); + expect(result.current.showOnboarding).toBe(true); + }); + + it("hides onboarding after completeOnboarding is called", () => { + const { result } = renderHook(() => useOnboardingState()); + expect(result.current.showOnboarding).toBe(true); + + act(() => { + result.current.completeOnboarding(); + }); + + expect(result.current.showOnboarding).toBe(false); + }); + + it("persists completion to localStorage", () => { + const { result } = renderHook(() => useOnboardingState()); + + act(() => { + result.current.completeOnboarding(); + }); + + expect(window.localStorage.getItem("claw3d:onboarding:completed")).toBe( + "true", + ); + }); + + it("reads completion state from localStorage on mount", () => { + window.localStorage.setItem("claw3d:onboarding:completed", "true"); + const { result } = renderHook(() => useOnboardingState()); + expect(result.current.showOnboarding).toBe(false); + }); + + it("resets onboarding when resetOnboarding is called", () => { + const { result } = renderHook(() => useOnboardingState()); + + act(() => { + result.current.completeOnboarding(); + }); + expect(result.current.showOnboarding).toBe(false); + + act(() => { + result.current.resetOnboarding(); + }); + expect(result.current.showOnboarding).toBe(true); + expect(window.localStorage.getItem("claw3d:onboarding:completed")).toBeNull(); + }); +}); diff --git a/tests/unit/onboardingTypes.test.ts b/tests/unit/onboardingTypes.test.ts new file mode 100644 index 0000000..36c87b5 --- /dev/null +++ b/tests/unit/onboardingTypes.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { + getNextStep, + getPrevStep, + getStepIndex, + ONBOARDING_STEPS, +} from "@/features/onboarding/types"; + +describe("ONBOARDING_STEPS", () => { + it("has at least 3 steps", () => { + expect(ONBOARDING_STEPS.length).toBeGreaterThanOrEqual(3); + }); + + it("starts with welcome and ends with complete", () => { + expect(ONBOARDING_STEPS[0].id).toBe("welcome"); + expect(ONBOARDING_STEPS[ONBOARDING_STEPS.length - 1].id).toBe("complete"); + }); + + it("has unique step IDs", () => { + const ids = ONBOARDING_STEPS.map((s) => s.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("every step has a non-empty title and description", () => { + for (const step of ONBOARDING_STEPS) { + expect(step.title.length).toBeGreaterThan(0); + expect(step.description.length).toBeGreaterThan(0); + } + }); +}); + +describe("getStepIndex", () => { + it("returns 0 for welcome", () => { + expect(getStepIndex("welcome")).toBe(0); + }); + + it("returns last index for complete", () => { + expect(getStepIndex("complete")).toBe(ONBOARDING_STEPS.length - 1); + }); + + it("returns correct index for connect", () => { + const idx = ONBOARDING_STEPS.findIndex((s) => s.id === "connect"); + expect(getStepIndex("connect")).toBe(idx); + }); +}); + +describe("getNextStep", () => { + it("returns prerequisites after welcome", () => { + expect(getNextStep("welcome")).toBe("prerequisites"); + }); + + it("returns null after complete", () => { + expect(getNextStep("complete")).toBeNull(); + }); + + it("advances through all steps in order", () => { + let current = ONBOARDING_STEPS[0].id; + const visited: string[] = [current]; + let next = getNextStep(current); + while (next) { + visited.push(next); + current = next; + next = getNextStep(current); + } + expect(visited).toEqual(ONBOARDING_STEPS.map((s) => s.id)); + }); +}); + +describe("getPrevStep", () => { + it("returns null for welcome", () => { + expect(getPrevStep("welcome")).toBeNull(); + }); + + it("returns prerequisites for connect", () => { + expect(getPrevStep("connect")).toBe("prerequisites"); + }); + + it("navigates backward through all steps", () => { + let current = ONBOARDING_STEPS[ONBOARDING_STEPS.length - 1].id; + const visited: string[] = [current]; + let prev = getPrevStep(current); + while (prev) { + visited.push(prev); + current = prev; + prev = getPrevStep(current); + } + visited.reverse(); + expect(visited).toEqual(ONBOARDING_STEPS.map((s) => s.id)); + }); +});