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
This commit is contained in:
parent
c0d7ea5a3c
commit
47630e53f7
1 changed files with 195 additions and 131 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
// [nexus] Replacement onboarding wizard — 4-step flow: hardware detection, mode selection, provider selection, root directory
|
// [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.
|
// 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.
|
||||||
|
|
@ -21,10 +21,23 @@ 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 { ProviderSelectionStep } from "./onboarding/ProviderSelectionStep";
|
||||||
|
import { OnboardingSummaryStep } from "./onboarding/OnboardingSummaryStep";
|
||||||
import { useHardwareInfo } from "../hooks/useHardwareInfo";
|
import { useHardwareInfo } from "../hooks/useHardwareInfo";
|
||||||
import { updateNexusSettings, type NexusMode } from "../api/hardware";
|
import { updateNexusSettings, type NexusMode } from "../api/hardware";
|
||||||
|
import { useChatPanel } from "../context/ChatPanelContext";
|
||||||
|
|
||||||
// [nexus] 4-step onboarding wizard: hardware detection → mode selection → provider selection → root directory
|
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() {
|
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();
|
||||||
|
|
@ -33,6 +46,7 @@ export function OnboardingWizard() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
|
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
|
||||||
const [routeDismissed, setRouteDismissed] = useState(false);
|
const [routeDismissed, setRouteDismissed] = useState(false);
|
||||||
|
const { setChatOpen } = useChatPanel();
|
||||||
|
|
||||||
// Preserve wizard-show detection logic from the original OnboardingWizard
|
// Preserve wizard-show detection logic from the original OnboardingWizard
|
||||||
const routeOnboardingOptions =
|
const routeOnboardingOptions =
|
||||||
|
|
@ -51,7 +65,7 @@ export function OnboardingWizard() {
|
||||||
setRouteDismissed(false);
|
setRouteDismissed(false);
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
// Step state: 1 = hardware detection, 2 = mode selection, 3 = provider selection, 4 = root directory
|
// Step state: 1 = hardware detection, 2 = mode selection, 3 = provider selection, 4 = root directory, 5 = summary
|
||||||
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
|
||||||
|
|
@ -116,6 +130,101 @@ export function OnboardingWizard() {
|
||||||
closeOnboarding();
|
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) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (defaultAdapter === "claude_local" && !rootDir.trim()) return;
|
if (defaultAdapter === "claude_local" && !rootDir.trim()) return;
|
||||||
|
|
@ -124,95 +233,7 @@ export function OnboardingWizard() {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Create workspace (company) named after VOCAB.appName
|
const company = await createWorkspace();
|
||||||
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(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate to dashboard — not an issue detail page
|
// Navigate to dashboard — not an issue detail page
|
||||||
closeOnboarding();
|
closeOnboarding();
|
||||||
|
|
@ -223,6 +244,30 @@ export function OnboardingWizard() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [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;
|
if (!effectiveOnboardingOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -242,7 +287,9 @@ export function OnboardingWizard() {
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Step indicator */}
|
{/* Step indicator */}
|
||||||
<p className="text-xs text-muted-foreground text-center">Step {step} of 4</p>
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
|
{step === 5 ? "Summary" : `Step ${step} of 4`}
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* Step 1 — Hardware Detection */}
|
{/* Step 1 — Hardware Detection */}
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
|
|
@ -259,13 +306,23 @@ export function OnboardingWizard() {
|
||||||
isError={hwError}
|
isError={hwError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<div className="flex flex-col gap-2">
|
||||||
type="button"
|
<Button
|
||||||
onClick={() => setStep(2)}
|
type="button"
|
||||||
className="w-full"
|
onClick={() => setStep(2)}
|
||||||
>
|
className="w-full"
|
||||||
Continue
|
>
|
||||||
</Button>
|
Continue
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setStep(2)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -296,6 +353,14 @@ export function OnboardingWizard() {
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setStep(3)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -348,7 +413,7 @@ export function OnboardingWizard() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label
|
<label
|
||||||
htmlFor="nexus-root-dir"
|
htmlFor="nexus-root-dir"
|
||||||
|
|
@ -376,37 +441,12 @@ export function OnboardingWizard() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="button"
|
||||||
disabled={loading || probing || (defaultAdapter === "claude_local" && !rootDir.trim())}
|
onClick={() => setStep(5)}
|
||||||
|
disabled={loading || probing}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{loading ? (
|
Review & finish
|
||||||
<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>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -418,9 +458,33 @@ export function OnboardingWizard() {
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue