- Refactor to 3-step flow: hardware detection, mode selection, root directory - Add step indicator 'Step N of 3' - Add HardwareSummaryStep on step 1 with dynamic heading - Add ModeSelector on step 2 with 'both' pre-selected - Add Back buttons on steps 2 and 3 - Persist selected mode via updateNexusSettings on wizard completion - Reset step and mode on wizard close
365 lines
13 KiB
TypeScript
365 lines
13 KiB
TypeScript
// [nexus] Replacement onboarding wizard — 3-step flow: hardware detection, mode 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.
|
|
|
|
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";
|
|
import { ModeSelector } from "./onboarding/ModeSelector";
|
|
import { HardwareSummaryStep } from "./onboarding/HardwareSummaryStep";
|
|
import { useHardwareInfo } from "../hooks/useHardwareInfo";
|
|
import { updateNexusSettings, type NexusMode } from "../api/hardware";
|
|
|
|
// [nexus] 3-step onboarding wizard: hardware detection → mode selection → root directory
|
|
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]);
|
|
|
|
// Step state: 1 = hardware detection, 2 = mode selection, 3 = root directory
|
|
const [step, setStep] = useState(1);
|
|
|
|
// Mode state: "both" pre-selected per UI-SPEC
|
|
const [selectedMode, setSelectedMode] = useState<NexusMode>("both");
|
|
|
|
// 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 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);
|
|
setStep(1);
|
|
setSelectedMode("both");
|
|
}
|
|
}, [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 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
|
|
// (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),
|
|
});
|
|
|
|
// Persist selected mode — non-blocking
|
|
try {
|
|
await updateNexusSettings({ mode: selectedMode });
|
|
} catch {
|
|
// Non-blocking — mode defaults to "both" if save fails
|
|
}
|
|
|
|
// 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"
|
|
)}
|
|
>
|
|
{/* Step indicator */}
|
|
<p className="text-xs text-muted-foreground text-center">Step {step} of 3</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}
|
|
/>
|
|
|
|
<Button
|
|
type="button"
|
|
onClick={() => setStep(2)}
|
|
className="w-full"
|
|
>
|
|
Continue
|
|
</Button>
|
|
</>
|
|
)}
|
|
|
|
{/* 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>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Step 3 — Root Directory */}
|
|
{step === 3 && (
|
|
<>
|
|
{/* 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>
|
|
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => setStep(2)}
|
|
className="w-full"
|
|
disabled={loading}
|
|
>
|
|
Back
|
|
</Button>
|
|
</form>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DialogPortal>
|
|
);
|
|
}
|