feat(30-02): wire multi-step wizard in NexusOnboardingWizard

- Refactor to 3-step flow: hardware detection, mode selection, root directory
- Add step indicator 'Step N of 3'
- Add HardwareSummaryStep on step 1 with dynamic heading
- Add ModeSelector on step 2 with 'both' pre-selected
- Add Back buttons on steps 2 and 3
- Persist selected mode via updateNexusSettings on wizard completion
- Reset step and mode on wizard close
This commit is contained in:
Nexus Dev 2026-04-02 23:28:48 +00:00
parent f44235460a
commit e1bdb9a800

View file

@ -1,4 +1,4 @@
// [nexus] Replacement onboarding wizard — single-step root directory flow // [nexus] Replacement onboarding wizard — 3-step flow: hardware detection, mode selection, root directory
// Exports `OnboardingWizard` to match the named import in App.tsx. // Exports `OnboardingWizard` to match the named import in App.tsx.
// Wired via Vite alias: all imports of ./components/OnboardingWizard are // Wired via Vite alias: all imports of ./components/OnboardingWizard are
// redirected here at build time; the original file is preserved for upstream rebase. // redirected here at build time; the original file is preserved for upstream rebase.
@ -17,8 +17,12 @@ import { Dialog, DialogPortal } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { ModeSelector } from "./onboarding/ModeSelector";
import { HardwareSummaryStep } from "./onboarding/HardwareSummaryStep";
import { useHardwareInfo } from "../hooks/useHardwareInfo";
import { updateNexusSettings, type NexusMode } from "../api/hardware";
// [nexus] Single-step onboarding wizard: root directory → workspace + PM + Engineer // [nexus] 3-step onboarding wizard: hardware detection → mode selection → root directory
export function OnboardingWizard() { export function OnboardingWizard() {
const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog(); const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog();
const { companies, setSelectedCompanyId, loading: companiesLoading } = useCompany(); const { companies, setSelectedCompanyId, loading: companiesLoading } = useCompany();
@ -45,11 +49,20 @@ export function OnboardingWizard() {
setRouteDismissed(false); setRouteDismissed(false);
}, [location.pathname]); }, [location.pathname]);
// Step state: 1 = hardware detection, 2 = mode selection, 3 = root directory
const [step, setStep] = useState(1);
// Mode state: "both" pre-selected per UI-SPEC
const [selectedMode, setSelectedMode] = useState<NexusMode>("both");
// Form state // Form state
const [rootDir, setRootDir] = useState(""); const [rootDir, setRootDir] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// [nexus] Hardware detection — only fetch when wizard is open
const { data: hardwareInfo, isLoading: hwLoading, isError: hwError } = useHardwareInfo(effectiveOnboardingOpen);
// [nexus] Adapter detection state — probe for Hermes when wizard opens // [nexus] Adapter detection state — probe for Hermes when wizard opens
const [defaultAdapter, setDefaultAdapter] = useState<"claude_local" | "hermes_local">("claude_local"); const [defaultAdapter, setDefaultAdapter] = useState<"claude_local" | "hermes_local">("claude_local");
const [probing, setProbing] = useState(false); const [probing, setProbing] = useState(false);
@ -60,6 +73,8 @@ export function OnboardingWizard() {
setRootDir(""); setRootDir("");
setError(null); setError(null);
setLoading(false); setLoading(false);
setStep(1);
setSelectedMode("both");
} }
}, [effectiveOnboardingOpen]); }, [effectiveOnboardingOpen]);
@ -161,6 +176,13 @@ export function OnboardingWizard() {
queryKey: queryKeys.agents.list(company.id), queryKey: queryKeys.agents.list(company.id),
}); });
// Persist selected mode — non-blocking
try {
await updateNexusSettings({ mode: selectedMode });
} catch {
// Non-blocking — mode defaults to "both" if save fails
}
// Navigate to dashboard — not an issue detail page // Navigate to dashboard — not an issue detail page
closeOnboarding(); closeOnboarding();
navigate(`/${company.issuePrefix}/dashboard`); navigate(`/${company.issuePrefix}/dashboard`);
@ -188,80 +210,154 @@ export function OnboardingWizard() {
"p-8 flex flex-col gap-6" "p-8 flex flex-col gap-6"
)} )}
> >
{/* Header */} {/* Step indicator */}
<div className="flex flex-col gap-2 text-center"> <p className="text-xs text-muted-foreground text-center">Step {step} of 3</p>
<h1 className="text-2xl font-semibold tracking-tight">
Welcome to {VOCAB.appName}
</h1>
<p className="text-sm text-muted-foreground">
{defaultAdapter === "hermes_local"
? `${VOCAB.appName} will set up a local AI workspace with a ${VOCAB.ceo.toLowerCase()}, engineer, and generalist — no API key needed.`
: `Choose a project root directory. ${VOCAB.appName} will set up a ${VOCAB.ceo.toLowerCase()} and engineer to start working.`}
</p>
</div>
{/* Form */} {/* Step 1 — Hardware Detection */}
<form onSubmit={handleSubmit} className="flex flex-col gap-4"> {step === 1 && (
<div className="flex flex-col gap-2"> <>
<label <div className="flex flex-col gap-2 text-center">
htmlFor="nexus-root-dir" <h1 className="text-2xl font-semibold tracking-tight">
className="text-sm font-medium leading-none" {hwLoading ? "Detecting your hardware..." : "Your hardware"}
> </h1>
Project root directory{defaultAdapter === "hermes_local" ? " (optional)" : ""} </div>
</label>
<Input <HardwareSummaryStep
id="nexus-root-dir" hardwareInfo={hardwareInfo}
type="text" isLoading={hwLoading}
placeholder="~/projects/my-project" isError={hwError}
value={rootDir}
onChange={(e) => setRootDir(e.target.value)}
disabled={loading}
autoFocus
autoComplete="off"
className="font-mono text-sm"
/> />
</div>
{error && ( <Button
<p className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2"> type="button"
{error} onClick={() => setStep(2)}
</p> className="w-full"
)} >
Continue
</Button>
</>
)}
<Button {/* Step 2 — Mode Selection */}
type="submit" {step === 2 && (
disabled={loading || probing || (defaultAdapter === "claude_local" && !rootDir.trim())} <>
className="w-full" <div className="flex flex-col gap-2 text-center">
> <h1 className="text-2xl font-semibold tracking-tight">
{loading ? ( Choose your mode
<span className="flex items-center gap-2"> </h1>
<svg </div>
className="h-4 w-4 animate-spin"
viewBox="0 0 24 24" <ModeSelector value={selectedMode} onChange={setSelectedMode} />
fill="none"
aria-hidden="true" <div className="flex flex-col gap-2">
<Button
type="button"
onClick={() => setStep(3)}
className="w-full"
>
Continue
</Button>
<Button
type="button"
variant="ghost"
onClick={() => setStep(1)}
className="w-full"
>
Back
</Button>
</div>
</>
)}
{/* Step 3 — Root Directory */}
{step === 3 && (
<>
{/* Header */}
<div className="flex flex-col gap-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
Welcome to {VOCAB.appName}
</h1>
<p className="text-sm text-muted-foreground">
{defaultAdapter === "hermes_local"
? `${VOCAB.appName} will set up a local AI workspace with a ${VOCAB.ceo.toLowerCase()}, engineer, and generalist — no API key needed.`
: `Choose a project root directory. ${VOCAB.appName} will set up a ${VOCAB.ceo.toLowerCase()} and engineer to start working.`}
</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label
htmlFor="nexus-root-dir"
className="text-sm font-medium leading-none"
> >
<circle Project root directory{defaultAdapter === "hermes_local" ? " (optional)" : ""}
className="opacity-25" </label>
cx="12" <Input
cy="12" id="nexus-root-dir"
r="10" type="text"
stroke="currentColor" placeholder="~/projects/my-project"
strokeWidth="4" value={rootDir}
/> onChange={(e) => setRootDir(e.target.value)}
<path disabled={loading}
className="opacity-75" autoFocus
fill="currentColor" autoComplete="off"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" className="font-mono text-sm"
/> />
</svg> </div>
Setting up
</span> {error && (
) : ( <p className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
"Get Started" {error}
)} </p>
</Button> )}
</form>
<Button
type="submit"
disabled={loading || probing || (defaultAdapter === "claude_local" && !rootDir.trim())}
className="w-full"
>
{loading ? (
<span className="flex items-center gap-2">
<svg
className="h-4 w-4 animate-spin"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Setting up
</span>
) : (
"Get Started"
)}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => setStep(2)}
className="w-full"
disabled={loading}
>
Back
</Button>
</form>
</>
)}
</div> </div>
</div> </div>
</DialogPortal> </DialogPortal>