feat(04-02): create NexusOnboardingWizard component
- Single-step wizard: root directory input only (no company name, mission, or first task) - Creates workspace named VOCAB.appName (Nexus) - Creates PM agent (role: ceo, for elevated permissions) + Engineer agent - Navigates to dashboard after completion, not issue detail - Preserves resolveRouteOnboardingOptions wizard-show detection logic - Exports OnboardingWizard to match named import in App.tsx - Original OnboardingWizard.tsx untouched for upstream rebase compatibility
This commit is contained in:
parent
5a32e2b652
commit
fdb45334dc
1 changed files with 219 additions and 0 deletions
219
ui/src/components/NexusOnboardingWizard.tsx
Normal file
219
ui/src/components/NexusOnboardingWizard.tsx
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
// [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);
|
||||
|
||||
// Reset form when wizard closes
|
||||
useEffect(() => {
|
||||
if (!effectiveOnboardingOpen) {
|
||||
setRootDir("");
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [effectiveOnboardingOpen]);
|
||||
|
||||
function handleClose() {
|
||||
setRouteDismissed(true);
|
||||
closeOnboarding();
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!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 });
|
||||
|
||||
const adapterConfig = { 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: "claude_local",
|
||||
adapterConfig,
|
||||
runtimeConfig,
|
||||
});
|
||||
|
||||
// Step 3: Create Engineer agent
|
||||
await agentsApi.create(company.id, {
|
||||
name: "Engineer",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
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">
|
||||
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
|
||||
</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 || !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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue