feat(31-03): add ProviderSelectionStep and wire 4-step onboarding wizard

- ProviderSelectionStep: three provider cards (Puter/Google/API key) with adapter badges
- Cards use border-primary bg-primary/5 when selected (matches ModeSelector pattern)
- PuterAuthButton/GoogleOAuthButton/ApiKeyEntryForm wired via callbacks
- NexusOnboardingWizard: step count 3→4, provider selection at step 3
- Parallel probe for hermes_local/claude_local/openclaw_gateway on wizard open
- Credentials stored after company creation (puterToken, googleOAuthStateId, apiKeyData)
- Skip always advances to step 4; Back from step 4 goes to step 3
This commit is contained in:
Nexus Dev 2026-04-03 00:42:41 +00:00
parent f6db1f7882
commit d9c6d121f3
2 changed files with 249 additions and 16 deletions

View file

@ -1,4 +1,4 @@
// [nexus] Replacement onboarding wizard — 3-step flow: hardware detection, mode selection, root directory
// [nexus] Replacement onboarding wizard — 4-step flow: hardware detection, mode selection, provider selection, root directory
// Exports `OnboardingWizard` to match the named import in App.tsx.
// Wired via Vite alias: all imports of ./components/OnboardingWizard are
// redirected here at build time; the original file is preserved for upstream rebase.
@ -11,6 +11,7 @@ import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { companiesApi } from "../api/companies";
import { agentsApi } from "../api/agents";
import { puterProxyApi } from "../api/puter-proxy";
import { queryKeys } from "../lib/queryKeys";
import { resolveRouteOnboardingOptions } from "../lib/onboarding-route";
import { Dialog, DialogPortal } from "@/components/ui/dialog";
@ -19,10 +20,11 @@ import { Input } from "@/components/ui/input";
import { cn } from "../lib/utils";
import { ModeSelector } from "./onboarding/ModeSelector";
import { HardwareSummaryStep } from "./onboarding/HardwareSummaryStep";
import { ProviderSelectionStep } from "./onboarding/ProviderSelectionStep";
import { useHardwareInfo } from "../hooks/useHardwareInfo";
import { updateNexusSettings, type NexusMode } from "../api/hardware";
// [nexus] 3-step onboarding wizard: hardware detection → mode selection → root directory
// [nexus] 4-step onboarding wizard: hardware detection → mode selection → provider selection → root directory
export function OnboardingWizard() {
const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog();
const { companies, setSelectedCompanyId, loading: companiesLoading } = useCompany();
@ -49,12 +51,17 @@ export function OnboardingWizard() {
setRouteDismissed(false);
}, [location.pathname]);
// Step state: 1 = hardware detection, 2 = mode selection, 3 = root directory
// Step state: 1 = hardware detection, 2 = mode selection, 3 = provider selection, 4 = root directory
const [step, setStep] = useState(1);
// Mode state: "both" pre-selected per UI-SPEC
const [selectedMode, setSelectedMode] = useState<NexusMode>("both");
// [nexus] Provider credentials — captured in React state for post-company-creation submission
const [puterToken, setPuterToken] = useState<string | null>(null);
const [googleOAuthStateId, setGoogleOAuthStateId] = useState<string | null>(null);
const [apiKeyData, setApiKeyData] = useState<{ provider: string; apiKey: string } | null>(null);
// Form state
const [rootDir, setRootDir] = useState("");
const [loading, setLoading] = useState(false);
@ -63,9 +70,10 @@ export function OnboardingWizard() {
// [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 adapters when wizard opens
const [defaultAdapter, setDefaultAdapter] = useState<"claude_local" | "hermes_local">("claude_local");
const [probing, setProbing] = useState(false);
const [detectedAdapters, setDetectedAdapters] = useState<Record<string, boolean>>({});
// Reset form when wizard closes
useEffect(() => {
@ -75,19 +83,32 @@ export function OnboardingWizard() {
setLoading(false);
setStep(1);
setSelectedMode("both");
setPuterToken(null);
setGoogleOAuthStateId(null);
setApiKeyData(null);
}
}, [effectiveOnboardingOpen]);
// [nexus] Probe for Hermes availability when wizard opens
// [nexus] Probe for adapter availability when wizard opens (parallel, fire-and-forget)
useEffect(() => {
if (!effectiveOnboardingOpen) return;
setProbing(true);
agentsApi.probeAdapter("hermes_local")
.then((data) => {
if (data.available) setDefaultAdapter("hermes_local");
})
.catch(() => {}) // graceful — keep claude_local
.finally(() => setProbing(false));
const probes = Promise.all([
agentsApi.probeAdapter("hermes_local").catch(() => ({ available: false, status: "error" })),
agentsApi.probeAdapter("claude_local").catch(() => ({ available: false, status: "error" })),
agentsApi.probeAdapter("openclaw_gateway").catch(() => ({ available: false, status: "error" })),
]);
probes.then(([hermes, claude, openclaw]) => {
const detected: Record<string, boolean> = {
hermes_local: hermes.available,
claude_local: claude.available,
openclaw_gateway: openclaw.available,
};
setDetectedAdapters(detected);
if (hermes.available) setDefaultAdapter("hermes_local");
}).catch(() => {}).finally(() => setProbing(false));
}, [effectiveOnboardingOpen]);
function handleClose() {
@ -153,8 +174,6 @@ export function OnboardingWizard() {
};
// Step 2: Create PM agent with role "ceo" for elevated permissions
// (display label is "Project Manager" via AGENT_ROLE_LABELS; ceo/ bundle
// has been rewritten with PM content in 04-01)
await agentsApi.create(company.id, {
name: "Project Manager",
role: "ceo",
@ -183,6 +202,18 @@ export function OnboardingWizard() {
// Non-blocking — mode defaults to "both" if save fails
}
// [nexus] Store collected provider credentials after company creation — all non-blocking
if (puterToken) {
await puterProxyApi.storeToken(company.id, puterToken).catch(() => {});
}
if (googleOAuthStateId) {
// Claim Google OAuth tokens for this company
await puterProxyApi.claimGoogleTokens(googleOAuthStateId, company.id).catch(() => {});
}
if (apiKeyData) {
await puterProxyApi.storeApiKey(company.id, apiKeyData.provider, apiKeyData.apiKey).catch(() => {});
}
// Navigate to dashboard — not an issue detail page
closeOnboarding();
navigate(`/${company.issuePrefix}/dashboard`);
@ -211,7 +242,7 @@ export function OnboardingWizard() {
)}
>
{/* Step indicator */}
<p className="text-xs text-muted-foreground text-center">Step {step} of 3</p>
<p className="text-xs text-muted-foreground text-center">Step {step} of 4</p>
{/* Step 1 — Hardware Detection */}
{step === 1 && (
@ -269,8 +300,40 @@ export function OnboardingWizard() {
</>
)}
{/* Step 3 — Root Directory */}
{/* Step 3 — Provider Selection (NEW) */}
{step === 3 && (
<>
<div className="flex flex-col gap-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
Choose a provider
</h1>
<p className="text-sm text-muted-foreground">
No API keys needed for the zero-config path.
</p>
</div>
<ProviderSelectionStep
onPuterToken={setPuterToken}
onGoogleOAuthState={setGoogleOAuthStateId}
onApiKey={(provider, apiKey) => setApiKeyData({ provider, apiKey })}
onSkip={() => setStep(4)}
onContinue={() => setStep(4)}
detectedAdapters={detectedAdapters}
/>
<Button
type="button"
variant="ghost"
onClick={() => setStep(2)}
className="w-full"
>
Back
</Button>
</>
)}
{/* Step 4 — Root Directory (was step 3) */}
{step === 4 && (
<>
{/* Header */}
<div className="flex flex-col gap-2 text-center">
@ -349,7 +412,7 @@ export function OnboardingWizard() {
<Button
type="button"
variant="ghost"
onClick={() => setStep(2)}
onClick={() => setStep(3)}
className="w-full"
disabled={loading}
>

View file

@ -0,0 +1,170 @@
// [nexus] Provider selection step — Step 3 of 4 in the onboarding wizard
// Heading: "Choose a provider" / Subheading: "No API keys needed for the zero-config path."
// Three provider cards (Puter, Google, API key) with adapter badges and skip button
import { useState } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { PuterAuthButton } from "./PuterAuthButton";
import { GoogleOAuthButton } from "./GoogleOAuthButton";
import { ApiKeyEntryForm } from "./ApiKeyEntryForm";
interface ProviderSelectionStepProps {
onPuterToken: (token: string) => void;
onGoogleOAuthState: (stateId: string) => void;
onApiKey: (provider: string, apiKey: string) => void;
onSkip: () => void;
onContinue: () => void;
detectedAdapters: Record<string, boolean>;
}
type ProviderChoice = "puter" | "google" | "apikey" | null;
export function ProviderSelectionStep({
onPuterToken,
onGoogleOAuthState,
onApiKey,
onSkip,
onContinue,
detectedAdapters,
}: ProviderSelectionStepProps) {
const [selectedProvider, setSelectedProvider] = useState<ProviderChoice>(null);
const [providerReady, setProviderReady] = useState(false);
const [error, setError] = useState<string | null>(null);
function handleSelect(provider: ProviderChoice) {
setSelectedProvider(provider);
setProviderReady(false);
setError(null);
}
const hermesDetected = detectedAdapters["hermes_local"] === true;
const claudeDetected = detectedAdapters["claude_local"] === true;
const openclawDetected = detectedAdapters["openclaw_gateway"] === true;
return (
<div className="flex flex-col gap-4">
{/* Three provider cards */}
<div className="flex flex-col gap-3">
{/* Puter card */}
<button
type="button"
onClick={() => handleSelect("puter")}
className={cn(
"flex flex-col gap-1 rounded-lg border p-4 text-left transition-colors",
selectedProvider === "puter"
? "border-primary bg-primary/5"
: "border-border hover:border-muted-foreground/50"
)}
>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm">Puter -- free, zero-config</span>
{hermesDetected && (
<span className="text-xs text-primary">Hermes detected</span>
)}
{claudeDetected && (
<span className="text-xs text-primary">Claude Code detected</span>
)}
{openclawDetected && (
<span className="text-xs text-primary">OpenClaw detected</span>
)}
</div>
<span className="text-xs text-muted-foreground">
Free AI powered by your Puter.com account. No API key needed.
</span>
</button>
{/* Puter auth component — shown when Puter is selected */}
{selectedProvider === "puter" && (
<PuterAuthButton
onSuccess={(token) => {
onPuterToken(token);
setProviderReady(true);
}}
onError={setError}
/>
)}
{/* Google card */}
<button
type="button"
onClick={() => handleSelect("google")}
className={cn(
"flex flex-col gap-1 rounded-lg border p-4 text-left transition-colors",
selectedProvider === "google"
? "border-primary bg-primary/5"
: "border-border hover:border-muted-foreground/50"
)}
>
<span className="font-medium text-sm">Google -- Gemini free tier</span>
<span className="text-xs text-muted-foreground">
Sign in with Google to access Gemini via your Google account.
</span>
</button>
{/* Google OAuth component — shown when Google is selected */}
{selectedProvider === "google" && (
<GoogleOAuthButton
onSuccess={(stateId) => {
onGoogleOAuthState(stateId);
setProviderReady(true);
}}
onError={setError}
/>
)}
{/* API key card */}
<button
type="button"
onClick={() => handleSelect("apikey")}
className={cn(
"flex flex-col gap-1 rounded-lg border p-4 text-left transition-colors",
selectedProvider === "apikey"
? "border-primary bg-primary/5"
: "border-border hover:border-muted-foreground/50"
)}
>
<span className="font-medium text-sm">API key -- subscription provider</span>
<span className="text-xs text-muted-foreground">
Use your own OpenAI, Anthropic, or Groq API key.
</span>
</button>
{/* API key form — shown when API key is selected */}
{selectedProvider === "apikey" && (
<ApiKeyEntryForm
onSave={(prov, key) => {
onApiKey(prov, key);
setProviderReady(true);
}}
onError={setError}
/>
)}
</div>
{/* Error display */}
{error && (
<p className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
{error}
</p>
)}
{/* Continue button — shown when provider auth is complete */}
{providerReady && (
<Button type="button" onClick={onContinue} className="w-full">
Continue
</Button>
)}
{/* Skip button — always visible */}
<Button
type="button"
variant="ghost"
onClick={onSkip}
aria-label="Skip provider setup for now"
className="w-full"
>
Skip for now
</Button>
</div>
);
}