feat(32-01): wire summary step, skip buttons, chat handoff in wizard

- Add OnboardingSummaryStep as step 5 of the wizard
- Add Skip buttons on step 1 (hardware) and step 2 (mode)
- Replace step 4 form submit with Review & finish -> step 5 flow
- Add Skip to summary on step 4
- Step indicator shows 'Summary' on step 5 instead of 'Step 5 of 4'
- Add deriveProviderLabel helper for provider display text
- Add handleStartChat that creates workspace then calls setChatOpen(true)
- Refactor shared workspace creation into createWorkspace() helper
This commit is contained in:
Nexus Dev 2026-04-03 21:36:13 +00:00
parent c0d7ea5a3c
commit 47630e53f7

View file

@ -1,4 +1,4 @@
// [nexus] Replacement onboarding wizard — 4-step flow: hardware detection, mode selection, provider selection, root directory // [nexus] Replacement onboarding wizard — 5-step flow: hardware detection, mode selection, provider selection, root directory, summary
// 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.
@ -21,10 +21,23 @@ import { cn } from "../lib/utils";
import { ModeSelector } from "./onboarding/ModeSelector"; import { ModeSelector } from "./onboarding/ModeSelector";
import { HardwareSummaryStep } from "./onboarding/HardwareSummaryStep"; import { HardwareSummaryStep } from "./onboarding/HardwareSummaryStep";
import { ProviderSelectionStep } from "./onboarding/ProviderSelectionStep"; import { ProviderSelectionStep } from "./onboarding/ProviderSelectionStep";
import { OnboardingSummaryStep } from "./onboarding/OnboardingSummaryStep";
import { useHardwareInfo } from "../hooks/useHardwareInfo"; import { useHardwareInfo } from "../hooks/useHardwareInfo";
import { updateNexusSettings, type NexusMode } from "../api/hardware"; import { updateNexusSettings, type NexusMode } from "../api/hardware";
import { useChatPanel } from "../context/ChatPanelContext";
// [nexus] 4-step onboarding wizard: hardware detection → mode selection → provider selection → root directory 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";
}
// [nexus] 5-step onboarding wizard: hardware detection → mode selection → provider selection → root directory → summary
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();
@ -33,6 +46,7 @@ export function OnboardingWizard() {
const location = useLocation(); const location = useLocation();
const { companyPrefix } = useParams<{ companyPrefix?: string }>(); const { companyPrefix } = useParams<{ companyPrefix?: string }>();
const [routeDismissed, setRouteDismissed] = useState(false); const [routeDismissed, setRouteDismissed] = useState(false);
const { setChatOpen } = useChatPanel();
// Preserve wizard-show detection logic from the original OnboardingWizard // Preserve wizard-show detection logic from the original OnboardingWizard
const routeOnboardingOptions = const routeOnboardingOptions =
@ -51,7 +65,7 @@ export function OnboardingWizard() {
setRouteDismissed(false); setRouteDismissed(false);
}, [location.pathname]); }, [location.pathname]);
// Step state: 1 = hardware detection, 2 = mode selection, 3 = provider selection, 4 = root directory // Step state: 1 = hardware detection, 2 = mode selection, 3 = provider selection, 4 = root directory, 5 = summary
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
// Mode state: "both" pre-selected per UI-SPEC // Mode state: "both" pre-selected per UI-SPEC
@ -116,14 +130,8 @@ export function OnboardingWizard() {
closeOnboarding(); closeOnboarding();
} }
async function handleSubmit(e: React.FormEvent) { // [nexus] Shared workspace creation logic used by both handleSubmit (step 4 direct) and handleStartChat (step 5)
e.preventDefault(); async function createWorkspace() {
if (defaultAdapter === "claude_local" && !rootDir.trim()) return;
setLoading(true);
setError(null);
try {
// Step 1: Create workspace (company) named after VOCAB.appName // Step 1: Create workspace (company) named after VOCAB.appName
const company = await companiesApi.create({ name: VOCAB.appName }); const company = await companiesApi.create({ name: VOCAB.appName });
setSelectedCompanyId(company.id); setSelectedCompanyId(company.id);
@ -214,6 +222,19 @@ export function OnboardingWizard() {
await puterProxyApi.storeApiKey(company.id, apiKeyData.provider, apiKeyData.apiKey).catch(() => {}); await puterProxyApi.storeApiKey(company.id, apiKeyData.provider, apiKeyData.apiKey).catch(() => {});
} }
return company;
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (defaultAdapter === "claude_local" && !rootDir.trim()) return;
setLoading(true);
setError(null);
try {
const company = await createWorkspace();
// 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`);
@ -223,6 +244,30 @@ export function OnboardingWizard() {
} }
} }
// [nexus] ONBD-06: Creates workspace then opens chat panel after navigation
async function handleStartChat() {
// Guard: claude_local requires rootDir
if (defaultAdapter === "claude_local" && !rootDir.trim()) {
setError("Root directory is required for Claude Code. Go back to step 4 to set it.");
return;
}
setLoading(true);
setError(null);
try {
const company = await createWorkspace();
// Navigate to dashboard then open chat panel
closeOnboarding();
navigate(`/${company.issuePrefix}/dashboard`);
setChatOpen(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Setup failed. Please try again.");
setLoading(false);
}
}
if (!effectiveOnboardingOpen) return null; if (!effectiveOnboardingOpen) return null;
return ( return (
@ -242,7 +287,9 @@ export function OnboardingWizard() {
)} )}
> >
{/* Step indicator */} {/* Step indicator */}
<p className="text-xs text-muted-foreground text-center">Step {step} of 4</p> <p className="text-xs text-muted-foreground text-center">
{step === 5 ? "Summary" : `Step ${step} of 4`}
</p>
{/* Step 1 — Hardware Detection */} {/* Step 1 — Hardware Detection */}
{step === 1 && ( {step === 1 && (
@ -259,6 +306,7 @@ export function OnboardingWizard() {
isError={hwError} isError={hwError}
/> />
<div className="flex flex-col gap-2">
<Button <Button
type="button" type="button"
onClick={() => setStep(2)} onClick={() => setStep(2)}
@ -266,6 +314,15 @@ export function OnboardingWizard() {
> >
Continue Continue
</Button> </Button>
<Button
type="button"
variant="ghost"
onClick={() => setStep(2)}
className="w-full"
>
Skip
</Button>
</div>
</> </>
)} )}
@ -296,6 +353,14 @@ export function OnboardingWizard() {
> >
Back Back
</Button> </Button>
<Button
type="button"
variant="ghost"
onClick={() => setStep(3)}
className="w-full"
>
Skip
</Button>
</div> </div>
</> </>
)} )}
@ -348,7 +413,7 @@ export function OnboardingWizard() {
</div> </div>
{/* Form */} {/* Form */}
<form onSubmit={handleSubmit} className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label <label
htmlFor="nexus-root-dir" htmlFor="nexus-root-dir"
@ -376,37 +441,12 @@ export function OnboardingWizard() {
)} )}
<Button <Button
type="submit" type="button"
disabled={loading || probing || (defaultAdapter === "claude_local" && !rootDir.trim())} onClick={() => setStep(5)}
disabled={loading || probing}
className="w-full" className="w-full"
> >
{loading ? ( Review &amp; finish
<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>
<Button <Button
@ -418,9 +458,33 @@ export function OnboardingWizard() {
> >
Back Back
</Button> </Button>
</form>
<Button
type="button"
variant="ghost"
onClick={() => setStep(5)}
className="w-full"
disabled={loading}
>
Skip to summary
</Button>
</div>
</> </>
)} )}
{/* Step 5 — Summary */}
{step === 5 && (
<OnboardingSummaryStep
hardwareInfo={hardwareInfo}
selectedMode={selectedMode}
providerLabel={deriveProviderLabel(puterToken, googleOAuthStateId, apiKeyData)}
rootDir={rootDir}
loading={loading}
error={error}
onStartChat={handleStartChat}
onBack={() => setStep(4)}
/>
)}
</div> </div>
</div> </div>
</DialogPortal> </DialogPortal>