nexus/ui/src/components/NexusOnboardingWizard.tsx
Nexus Dev 4032d4d88b 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
2026-04-04 03:55:49 +00:00

492 lines
17 KiB
TypeScript

// [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.
// Wired via Vite alias: all imports of ./components/OnboardingWizard are
// redirected here at build time; the original file is preserved for upstream rebase.
import { useState, useEffect } from "react";
import { useLocation, useNavigate, useParams } from "@/lib/router";
import { VOCAB } from "@paperclipai/branding";
import { useQueryClient } from "@tanstack/react-query";
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";
import { Button } from "@/components/ui/button";
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 { OnboardingSummaryStep } from "./onboarding/OnboardingSummaryStep";
import { useHardwareInfo } from "../hooks/useHardwareInfo";
import { updateNexusSettings, type NexusMode } from "../api/hardware";
import { useChatPanel } from "../context/ChatPanelContext";
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() {
const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog();
const { companies, setSelectedCompanyId, loading: companiesLoading } = useCompany();
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
const [routeDismissed, setRouteDismissed] = useState(false);
const { setChatOpen } = useChatPanel();
// Preserve wizard-show detection logic from the original OnboardingWizard
const routeOnboardingOptions =
companyPrefix && companiesLoading
? null
: resolveRouteOnboardingOptions({
pathname: location.pathname,
companyPrefix,
companies,
});
const effectiveOnboardingOpen =
onboardingOpen || (routeOnboardingOptions !== null && !routeDismissed);
useEffect(() => {
setRouteDismissed(false);
}, [location.pathname]);
// Step state: 1 = hardware detection, 2 = mode selection, 3 = provider selection, 4 = root directory, 5 = summary
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);
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 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(() => {
if (!effectiveOnboardingOpen) {
setRootDir("");
setError(null);
setLoading(false);
setStep(1);
setSelectedMode("both");
setPuterToken(null);
setGoogleOAuthStateId(null);
setApiKeyData(null);
}
}, [effectiveOnboardingOpen]);
// [nexus] Probe for adapter availability when wizard opens (parallel, fire-and-forget)
useEffect(() => {
if (!effectiveOnboardingOpen) return;
setProbing(true);
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() {
setRouteDismissed(true);
closeOnboarding();
}
// [nexus] Shared workspace creation logic used by both handleSubmit (step 4 direct) and handleStartChat (step 5)
async function createWorkspace() {
// Step 1: Create workspace (company) named after VOCAB.appName
const company = await companiesApi.create({ name: VOCAB.appName });
setSelectedCompanyId(company.id);
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
// [nexus] hermes_local doesn't require a cwd; directory is optional
const baseAdapterConfig = defaultAdapter === "hermes_local"
? (rootDir.trim() ? { cwd: rootDir.trim() } : {})
: { cwd: rootDir.trim() };
// [nexus] Hermes agents need a promptTemplate so they follow the Nexus heartbeat
// workflow. The server's ensureDefaultInstructionsBundle will materialize this as
// AGENTS.md alongside the full role bundle (HEARTBEAT.md, SOUL.md, TOOLS.md).
const hermesPromptTemplate = [
`You are "{{agentName}}", an AI agent managed by ${VOCAB.appName}.`,
"",
"Your identity:",
" Agent ID: {{agentId}}",
" Company ID: {{companyId}}",
" API Base: {{paperclipApiUrl}}",
" Run ID: {{runId}}",
"",
"IMPORTANT: Use the `terminal` tool with `curl` for ALL API calls.",
'IMPORTANT: Always include `-H "X-Paperclip-Run-Id: {{runId}}"` on API calls that modify data.',
"",
"Before starting any task:",
"1. Call `GET {{paperclipApiUrl}}/api/agents/me` to retrieve your managed instructions",
"2. Follow the HEARTBEAT.md workflow from your instructions",
"3. Use TOOLS.md for available API endpoints",
"",
"{{#taskId}}",
"Assigned task: {{taskId}} - {{taskTitle}}",
"{{/taskId}}",
].join("\n");
const adapterConfig = defaultAdapter === "hermes_local"
? { ...baseAdapterConfig, promptTemplate: hermesPromptTemplate, persistSession: true }
: baseAdapterConfig;
const runtimeConfig = {
heartbeat: {
enabled: true,
intervalSec: 3600,
wakeOnDemand: true,
cooldownSec: 10,
maxConcurrentRuns: 1,
},
};
// Step 2: Create PM agent with role "ceo" for elevated permissions
await agentsApi.create(company.id, {
name: "Project Manager",
role: "ceo",
adapterType: defaultAdapter,
adapterConfig,
runtimeConfig,
});
// Step 3: Create Engineer agent
await agentsApi.create(company.id, {
name: "Engineer",
role: "engineer",
adapterType: defaultAdapter,
adapterConfig,
runtimeConfig,
});
queryClient.invalidateQueries({
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
}
// [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(() => {});
}
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
closeOnboarding();
navigate(`/${company.issuePrefix}/dashboard`);
} catch (err) {
setError(err instanceof Error ? err.message : "Setup failed. Please try again.");
setLoading(false);
}
}
// [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;
return (
<DialogPortal>
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={handleClose}
/>
{/* Card */}
<div
className={cn(
"relative z-10 w-full max-w-md mx-4 rounded-xl border bg-card text-card-foreground shadow-2xl",
"p-8 flex flex-col gap-6"
)}
>
{/* Step indicator */}
<p className="text-xs text-muted-foreground text-center">
{step === 5 ? "Summary" : `Step ${step} of 4`}
</p>
{/* Step 1 — Hardware Detection */}
{step === 1 && (
<>
<div className="flex flex-col gap-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
{hwLoading ? "Detecting your hardware..." : "Your hardware"}
</h1>
</div>
<HardwareSummaryStep
hardwareInfo={hardwareInfo}
isLoading={hwLoading}
isError={hwError}
/>
<div className="flex flex-col gap-2">
<Button
type="button"
onClick={() => setStep(2)}
className="w-full"
>
Continue
</Button>
<Button
type="button"
variant="ghost"
onClick={() => setStep(2)}
className="w-full"
>
Skip
</Button>
</div>
</>
)}
{/* Step 2 — Mode Selection */}
{step === 2 && (
<>
<div className="flex flex-col gap-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
Choose your mode
</h1>
</div>
<ModeSelector value={selectedMode} onChange={setSelectedMode} />
<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>
<Button
type="button"
variant="ghost"
onClick={() => setStep(3)}
className="w-full"
>
Skip
</Button>
</div>
</>
)}
{/* 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">
<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 */}
<div 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"
>
Project root directory{defaultAdapter === "hermes_local" ? " (optional)" : ""}
</label>
<Input
id="nexus-root-dir"
type="text"
placeholder="~/projects/my-project"
value={rootDir}
onChange={(e) => setRootDir(e.target.value)}
disabled={loading}
autoFocus
autoComplete="off"
className="font-mono text-sm"
/>
</div>
{error && (
<p className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
{error}
</p>
)}
<Button
type="button"
onClick={() => setStep(5)}
disabled={loading || probing}
className="w-full"
>
Review &amp; finish
</Button>
<Button
type="button"
variant="ghost"
onClick={() => setStep(3)}
className="w-full"
disabled={loading}
>
Back
</Button>
<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>
</DialogPortal>
);
}