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
3796de8493
commit
cdea714d56
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.
|
// 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.
|
||||||
|
|
@ -11,6 +11,7 @@ import { useCompany } from "../context/CompanyContext";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { companiesApi } from "../api/companies";
|
import { companiesApi } from "../api/companies";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
|
import { puterProxyApi } from "../api/puter-proxy";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { resolveRouteOnboardingOptions } from "../lib/onboarding-route";
|
import { resolveRouteOnboardingOptions } from "../lib/onboarding-route";
|
||||||
import { Dialog, DialogPortal } from "@/components/ui/dialog";
|
import { Dialog, DialogPortal } from "@/components/ui/dialog";
|
||||||
|
|
@ -19,10 +20,11 @@ import { Input } from "@/components/ui/input";
|
||||||
import { cn } from "../lib/utils";
|
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 { useHardwareInfo } from "../hooks/useHardwareInfo";
|
import { useHardwareInfo } from "../hooks/useHardwareInfo";
|
||||||
import { updateNexusSettings, type NexusMode } from "../api/hardware";
|
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() {
|
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();
|
||||||
|
|
@ -49,12 +51,17 @@ export function OnboardingWizard() {
|
||||||
setRouteDismissed(false);
|
setRouteDismissed(false);
|
||||||
}, [location.pathname]);
|
}, [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);
|
const [step, setStep] = useState(1);
|
||||||
|
|
||||||
// Mode state: "both" pre-selected per UI-SPEC
|
// Mode state: "both" pre-selected per UI-SPEC
|
||||||
const [selectedMode, setSelectedMode] = useState<NexusMode>("both");
|
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
|
// Form state
|
||||||
const [rootDir, setRootDir] = useState("");
|
const [rootDir, setRootDir] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -63,9 +70,10 @@ export function OnboardingWizard() {
|
||||||
// [nexus] Hardware detection — only fetch when wizard is open
|
// [nexus] Hardware detection — only fetch when wizard is open
|
||||||
const { data: hardwareInfo, isLoading: hwLoading, isError: hwError } = useHardwareInfo(effectiveOnboardingOpen);
|
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 [defaultAdapter, setDefaultAdapter] = useState<"claude_local" | "hermes_local">("claude_local");
|
||||||
const [probing, setProbing] = useState(false);
|
const [probing, setProbing] = useState(false);
|
||||||
|
const [detectedAdapters, setDetectedAdapters] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
// Reset form when wizard closes
|
// Reset form when wizard closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -75,19 +83,32 @@ export function OnboardingWizard() {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setStep(1);
|
setStep(1);
|
||||||
setSelectedMode("both");
|
setSelectedMode("both");
|
||||||
|
setPuterToken(null);
|
||||||
|
setGoogleOAuthStateId(null);
|
||||||
|
setApiKeyData(null);
|
||||||
}
|
}
|
||||||
}, [effectiveOnboardingOpen]);
|
}, [effectiveOnboardingOpen]);
|
||||||
|
|
||||||
// [nexus] Probe for Hermes availability when wizard opens
|
// [nexus] Probe for adapter availability when wizard opens (parallel, fire-and-forget)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!effectiveOnboardingOpen) return;
|
if (!effectiveOnboardingOpen) return;
|
||||||
setProbing(true);
|
setProbing(true);
|
||||||
agentsApi.probeAdapter("hermes_local")
|
|
||||||
.then((data) => {
|
const probes = Promise.all([
|
||||||
if (data.available) setDefaultAdapter("hermes_local");
|
agentsApi.probeAdapter("hermes_local").catch(() => ({ available: false, status: "error" })),
|
||||||
})
|
agentsApi.probeAdapter("claude_local").catch(() => ({ available: false, status: "error" })),
|
||||||
.catch(() => {}) // graceful — keep claude_local
|
agentsApi.probeAdapter("openclaw_gateway").catch(() => ({ available: false, status: "error" })),
|
||||||
.finally(() => setProbing(false));
|
]);
|
||||||
|
|
||||||
|
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]);
|
}, [effectiveOnboardingOpen]);
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
|
|
@ -153,8 +174,6 @@ export function OnboardingWizard() {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Step 2: Create PM agent with role "ceo" for elevated permissions
|
// 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, {
|
await agentsApi.create(company.id, {
|
||||||
name: "Project Manager",
|
name: "Project Manager",
|
||||||
role: "ceo",
|
role: "ceo",
|
||||||
|
|
@ -183,6 +202,18 @@ export function OnboardingWizard() {
|
||||||
// Non-blocking — mode defaults to "both" if save fails
|
// 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
|
// Navigate to dashboard — not an issue detail page
|
||||||
closeOnboarding();
|
closeOnboarding();
|
||||||
navigate(`/${company.issuePrefix}/dashboard`);
|
navigate(`/${company.issuePrefix}/dashboard`);
|
||||||
|
|
@ -211,7 +242,7 @@ export function OnboardingWizard() {
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Step indicator */}
|
{/* 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 — Hardware Detection */}
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
|
|
@ -269,8 +300,40 @@ export function OnboardingWizard() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 3 — Root Directory */}
|
{/* Step 3 — Provider Selection (NEW) */}
|
||||||
{step === 3 && (
|
{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 */}
|
{/* Header */}
|
||||||
<div className="flex flex-col gap-2 text-center">
|
<div className="flex flex-col gap-2 text-center">
|
||||||
|
|
@ -349,7 +412,7 @@ export function OnboardingWizard() {
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setStep(2)}
|
onClick={() => setStep(3)}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={loading}
|
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