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:
parent
f6db1f7882
commit
d9c6d121f3
2 changed files with 249 additions and 16 deletions
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
170
ui/src/components/onboarding/ProviderSelectionStep.tsx
Normal file
170
ui/src/components/onboarding/ProviderSelectionStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue