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:
parent
f44235460a
commit
e1bdb9a800
1 changed files with 167 additions and 71 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue