import { Trans, useLingui } from "@lingui/react/macro"; import type { IntegrationSetupState } from "@rakazo/contracts"; import { Button, Input } from "@rakazo/ui-web"; import { useEffect, useId, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { IntegrationSetup } from "../components/integrations/IntegrationSetup"; import { OnboardingDiscoveryShell } from "../components/onboarding/OnboardingDiscoveryShell"; import { type OnboardingEmojiConfig, OnboardingOoDesigner, } from "../components/onboarding/OnboardingOoDesigner"; import { OnboardingScanProgress } from "../components/onboarding/OnboardingScanProgress"; import { KOKORO_LOCAL_TOKEN, OnboardingVoiceStep, } from "../components/onboarding/OnboardingVoiceStep"; import { computeDiscoveryItems, countConfiguredIntegrations, type OnboardingDiscoveryPhase, } from "../lib/onboarding-discovery"; import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-look-categories"; import { rpc } from "../lib/rpc"; import { persistUiLocale, UI_LOCALE_LABELS, type UiLocale } from "../lib/ui-locale"; import { inferHelpVibeHints, type UserHelpVibeHints } from "../lib/user-help-vibe"; import { collectLocalProfileSignals, persistUserProfileHarvest } from "../lib/user-profile-harvest"; const FIRST_BOT_NAME = "Chief"; const FIRST_BOT_SPAWN_KEY = "onboarding:first"; const FIRST_BOT_LOCK = "rakazo:onboarding-first-bot"; let firstBotEnsure: Promise<{ id: string }> | null = null; function findFirstBot( bots: Array<{ id: string; name: string; spawnKey: string | null }>, ): { id: string } | undefined { const bySpawnKey = bots.find((bot) => bot.spawnKey === FIRST_BOT_SPAWN_KEY); if (bySpawnKey) return { id: bySpawnKey.id }; const byName = bots.find((bot) => bot.name === FIRST_BOT_NAME); return byName ? { id: byName.id } : undefined; } async function createOrReuseFirstBot(avatarColor?: string): Promise<{ id: string }> { const existing = await rpc.bots.list(); const reuse = findFirstBot(existing); if (reuse) return reuse; try { const created = await rpc.bots.create({ name: FIRST_BOT_NAME, title: "Chief of Staff", description: "Coordinates specialists — routes work, never a do-everything generalist.", instructions: "", notifyOnFinish: true, spawnKey: FIRST_BOT_SPAWN_KEY, color: avatarColor, }); return { id: created.id }; } catch (error) { const afterConflict = await rpc.bots.list(); const winner = afterConflict.find((bot) => bot.spawnKey === FIRST_BOT_SPAWN_KEY); if (winner) return { id: winner.id }; throw error; } } async function withFirstBotLock(run: () => Promise): Promise { const locks = globalThis.navigator?.locks; if (!locks?.request) return run(); return locks.request(FIRST_BOT_LOCK, run); } async function ensureFirstBot(avatarColor?: string): Promise<{ id: string }> { if (firstBotEnsure) return firstBotEnsure; firstBotEnsure = withFirstBotLock(() => createOrReuseFirstBot(avatarColor)).finally(() => { firstBotEnsure = null; }); return firstBotEnsure; } function defaultEmojiConfig(): OnboardingEmojiConfig { const preset = findLookCategory("calm").looks[0]; if (!preset) { return { color: "#8B5CF6", shapeIndex: 0, accessory: "none", pose: "idle", avatarValue: "#8B5CF6::shape_0", }; } return { color: preset.color, shapeIndex: preset.shapeIndex, accessory: "none", pose: preset.pose, avatarValue: lookPresetToAvatarValue(preset), }; } interface UserPreferences { name: string; timezone: string; hobbies: string[]; workStyle: string; communication: string; } const HOBBY_OPTIONS = [ { key: "coding", label: "Coding" }, { key: "design", label: "Design" }, { key: "writing", label: "Writing" }, { key: "music", label: "Music" }, { key: "gaming", label: "Gaming" }, { key: "reading", label: "Reading" }, { key: "sports", label: "Sports" }, { key: "cooking", label: "Cooking" }, { key: "tech", label: "Tech & Gadgets" }, { key: "none", label: Nothing specific }, ]; const WORK_STYLE_OPTIONS = [ { key: "structured", label: Structured & planned }, { key: "flexible", label: Flexible & spontaneous }, { key: "deep-dive", label: Deep-dive focused }, { key: "quick", label: Quick & pragmatic }, ]; const COMMUNICATION_OPTIONS = [ { key: "direct", label: Direct & concise }, { key: "detailed", label: Detailed & explanatory }, { key: "balanced", label: Balanced }, { key: "casual", label: Casual & friendly }, ]; function defaultPreferences(): UserPreferences { return { name: "", timezone: "", hobbies: [], workStyle: "", communication: "", }; } export function OnboardingPage() { const { t } = useLingui(); const navigate = useNavigate(); const fieldId = useId(); const [step, setStep] = useState< "loading" | "emoji" | "preferences" | "language" | "voice" | "integrations" | "scan" | "bot" >("emoji"); const [selectedVoiceId, setSelectedVoiceId] = useState(""); const [voiceSkipped, setVoiceSkipped] = useState(false); const [integrationSetup, setIntegrationSetup] = useState(null); const needsIntegrationSetup = integrationSetup?.needsSetup ?? false; const [integrationServers, setIntegrationServers] = useState([]); const [error, setError] = useState(null); const [bootstrapMe, setBootstrapMe] = useState> | null>(null); const [profilePersisted, setProfilePersisted] = useState(false); const [crewReady, setCrewReady] = useState(false); const [_helpVibe, setHelpVibe] = useState(null); const [scanBotId, setScanBotId] = useState(null); const [emojiConfig, setEmojiConfig] = useState(defaultEmojiConfig); const [prefs, setPrefs] = useState(defaultPreferences()); const [currentLocale, setCurrentLocale] = useState(() => { try { return localStorage.getItem("rakazo.uiLocale") ?? "en"; } catch { return "en"; } }); useEffect(() => { let cancelled = false; const bootstrap = Promise.race([ Promise.all([rpc.me(), rpc.integrationSetup.get().catch(() => null)]), new Promise((_, reject) => { setTimeout(() => reject(new Error("onboarding bootstrap timeout")), 15_000); }), ]); void bootstrap .then(async ([me, integrations]) => { if (cancelled) return; setBootstrapMe(me); setHelpVibe(inferHelpVibeHints(collectLocalProfileSignals(me))); // Get bot ID for scan phase try { const botList = await rpc.bots.list(); const firstBot = botList.find( (b) => b.spawnKey === FIRST_BOT_SPAWN_KEY || b.name === FIRST_BOT_NAME, ); if (firstBot && !cancelled) setScanBotId(firstBot.id); } catch { /* best effort */ } setIntegrationSetup(integrations); setStep(me.needsModel ? "emoji" : integrations?.needsSetup ? "integrations" : "emoji"); // A space that already has a bot does not need the designer again. // Empty spaces (a new project) still walk onboarding. Resolve this after // the first step renders so a slow spaces list cannot leave E2E on loading. try { const navigation = await rpc.spaces.list(); if (cancelled) return; const existing = navigation.current.bots[0]; if (existing && !me.needsModel) { navigate(`/app/${existing.id}`, { replace: true }); } } catch { // Best effort; fresh signups stay on onboarding. } }) .catch(() => { if (!cancelled) setStep("emoji"); }); return () => { cancelled = true; }; }, [navigate]); function toggleHobby(key: string) { const hobbies = prefs.hobbies.includes(key) ? prefs.hobbies.filter((h) => h !== key) : [...prefs.hobbies, key]; setPrefs({ ...prefs, hobbies }); } const discoveryPhase: OnboardingDiscoveryPhase = step === "loading" ? "loading" : step === "emoji" || step === "preferences" || step === "language" || step === "voice" ? "emoji" : step === "integrations" ? "integrations" : "assembly"; const discoveryItems = useMemo( () => computeDiscoveryItems({ phase: discoveryPhase, me: bootstrapMe, integrationSetup, connectedIntegrationCount: Math.max( integrationServers.length, countConfiguredIntegrations(integrationSetup), ), profilePersisted, crewReady, }), [ bootstrapMe, crewReady, discoveryPhase, integrationServers.length, integrationSetup, profilePersisted, ], ); async function createFirstBot() { if (createStartedRef.current) return; createStartedRef.current = true; setError(null); try { const bot = await ensureFirstBot(emojiConfig.avatarValue); setCrewReady(true); const ctxParts: string[] = []; if (prefs.name) ctxParts.push(`User: ${prefs.name}`); if (prefs.timezone) ctxParts.push(`Timezone: ${prefs.timezone}`); if (prefs.hobbies.length > 0) ctxParts.push(`Hobbies: ${prefs.hobbies.join(", ")}`); if (prefs.workStyle) ctxParts.push(`Work style: ${prefs.workStyle}`); if (prefs.communication) ctxParts.push(`Communication: ${prefs.communication}`); const userCtx = ctxParts.length > 0 ? `## User Context\n${ctxParts.map((p) => `- ${p}`).join("\n")}` : ""; await rpc.bots.update({ botId: bot.id, instructions: userCtx }).catch(() => null); if (selectedVoiceId && !voiceSkipped) { await rpc.voice .connect({ provider: "kokoro", apiKey: KOKORO_LOCAL_TOKEN, voiceId: selectedVoiceId }) .catch(() => null); await rpc.voice .setVoice({ voiceId: selectedVoiceId, provider: "kokoro" }) .catch(() => null); await rpc.bots .update({ botId: bot.id, voiceId: selectedVoiceId, autoSpeak: true }) .catch(() => null); } const me = await rpc.me().catch(() => null); if (me) setBootstrapMe(me); const harvested = await persistUserProfileHarvest( rpc, collectLocalProfileSignals(me ?? undefined), ).catch(() => null); if (harvested) setHelpVibe(harvested); setProfilePersisted(true); for (const serverId of integrationServers) { await rpc.mcp.assignments.approve({ botId: bot.id, serverId }); } const started = await rpc.onboarding .start({ botId: bot.id }) .then(() => true) .catch(() => false); if (started) { await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined); } navigate(`/app/${bot.id}`); } catch (err) { createStartedRef.current = false; setError(err instanceof Error ? err.message : t`Could not create your bot`); } } const createStartedRef = useRef(false); useEffect(() => { if (step !== "bot") return; void createFirstBot(); }, [step]); function goAfterVoice(skipped: boolean) { setVoiceSkipped(skipped); setStep(needsIntegrationSetup ? "integrations" : "scan"); } return (
{error && step !== "bot" ?

{error}

: null} {step === "emoji" ? ( setStep("preferences")} /> ) : null} {step === "preferences" ? (

Tell us about yourself

A few quick preferences so your Oo can be better tailored to you.

setPrefs({ ...prefs, name: e.target.value })} placeholder={t`Your name`} className="mt-2" />
setPrefs({ ...prefs, timezone: e.target.value })} placeholder={t`Auto-detected or enter manually`} className="mt-2" />
Hobbies & interests

Select all that apply

{HOBBY_OPTIONS.map((hobby) => ( ))}
Work style

How do you like to work?

{WORK_STYLE_OPTIONS.map((ws) => ( ))}
Communication style

How should your Oo talk to you?

{COMMUNICATION_OPTIONS.map((comm) => ( ))}
) : null} {step === "language" ? (

Choose your language

Your Oo will communicate in this language. You can change it later.

{Object.entries(UI_LOCALE_LABELS).map(([code, label]) => ( ))}
) : null} {step === "voice" ? ( setStep("language")} onSkip={() => goAfterVoice(true)} onContinue={() => goAfterVoice(false)} /> ) : null} {step === "integrations" ? (

Connect tools when you''re ready

setStep("scan")} onServerConnected={(id) => setIntegrationServers((current) => [...new Set([...current, id])]) } />
) : null} {step === "scan" ? ( setStep("bot")} botId={scanBotId ?? undefined} /> ) : null} {step === "bot" && error ? (

{error}

) : null}
); }