nexus/ui/src/components/NexusOnboardingWizard.tsx
Nexus Dev 1ff3953c97 feat(29-01): adapter probe route, Hermes onboarding fallback, neutral templates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 03:55:49 +00:00

239 lines
8.3 KiB
TypeScript

// [nexus] Replacement onboarding wizard — single-step root directory flow
// 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 { 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";
// [nexus] Single-step onboarding wizard: root directory → workspace + PM + Engineer
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);
// 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]);
// Form state
const [rootDir, setRootDir] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// [nexus] Adapter detection state — probe for Hermes when wizard opens
const [defaultAdapter, setDefaultAdapter] = useState<"claude_local" | "hermes_local">("claude_local");
const [probing, setProbing] = useState(false);
// Reset form when wizard closes
useEffect(() => {
if (!effectiveOnboardingOpen) {
setRootDir("");
setError(null);
setLoading(false);
}
}, [effectiveOnboardingOpen]);
// [nexus] Probe for Hermes availability when wizard opens
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));
}, [effectiveOnboardingOpen]);
function handleClose() {
setRouteDismissed(true);
closeOnboarding();
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (defaultAdapter === "claude_local" && !rootDir.trim()) return;
setLoading(true);
setError(null);
try {
// 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 adapterConfig = defaultAdapter === "hermes_local"
? (rootDir.trim() ? { cwd: rootDir.trim() } : {})
: { cwd: rootDir.trim() };
const runtimeConfig = {
heartbeat: {
enabled: true,
intervalSec: 3600,
wakeOnDemand: true,
cooldownSec: 10,
maxConcurrentRuns: 1,
},
};
// 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",
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),
});
// 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);
}
}
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"
)}
>
{/* 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 */}
<form onSubmit={handleSubmit} 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="submit"
disabled={loading || probing || (defaultAdapter === "claude_local" && !rootDir.trim())}
className="w-full"
>
{loading ? (
<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>
</form>
</div>
</div>
</DialogPortal>
);
}