evOo-local/apps/web/src/pages/Onboarding.tsx
cynarAI 5426bb73c5 fix(e2e): wait for shell-ready after RPC bot create (#87)
* fix(e2e): wait for shell-ready after RPC bot create

createNamedBot now waits for data-ready and the Message composer after
navigating to a freshly created bot, instead of asserting immediately on
a cold page load where bootstrap had not finished.

Also fix onboarding scan advancing to bot creation (onComplete was a no-op)
and use a fast simulated scan when AGENT_RUNTIME=scripted so CI E2E does
not walk the real filesystem during onboarding.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* style: apply biome format to touched onboarding files

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* style: clear biome lint in onboarding scan files touched by the fix

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* style: format onboarding-scan after rebase onto main

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* style: fix biome lint on main files blocking Product CI

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(onboarding): allow scan before first bot exists

The scan RPC required a non-empty botId, but onboarding runs scan before
Chief is created and the UI sent an empty string. That rejected the RPC,
left the flow on /onboarding, and every completeOnboarding helper timed out.

Make botId optional, omit it from the client when unknown, advance to bot
creation when scan completes, and keep scripted/fake runs on the fast scan path.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-09-24 03:38:40 +02:00

567 lines
20 KiB
TypeScript

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<T>(run: () => Promise<T>): Promise<T> {
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: <Trans>Nothing specific</Trans> },
];
const WORK_STYLE_OPTIONS = [
{ key: "structured", label: <Trans>Structured & planned</Trans> },
{ key: "flexible", label: <Trans>Flexible & spontaneous</Trans> },
{ key: "deep-dive", label: <Trans>Deep-dive focused</Trans> },
{ key: "quick", label: <Trans>Quick & pragmatic</Trans> },
];
const COMMUNICATION_OPTIONS = [
{ key: "direct", label: <Trans>Direct & concise</Trans> },
{ key: "detailed", label: <Trans>Detailed & explanatory</Trans> },
{ key: "balanced", label: <Trans>Balanced</Trans> },
{ key: "casual", label: <Trans>Casual & friendly</Trans> },
];
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<IntegrationSetupState | null>(null);
const needsIntegrationSetup = integrationSetup?.needsSetup ?? false;
const [integrationServers, setIntegrationServers] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const [bootstrapMe, setBootstrapMe] = useState<Awaited<ReturnType<typeof rpc.me>> | null>(null);
const [profilePersisted, setProfilePersisted] = useState(false);
const [crewReady, setCrewReady] = useState(false);
const [_helpVibe, setHelpVibe] = useState<UserHelpVibeHints | null>(null);
const [scanBotId, setScanBotId] = useState<string | null>(null);
const [emojiConfig, setEmojiConfig] = useState<OnboardingEmojiConfig>(defaultEmojiConfig);
const [prefs, setPrefs] = useState<UserPreferences>(defaultPreferences());
const [currentLocale, setCurrentLocale] = useState<string>(() => {
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<never>((_, 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 (
<div
className="min-h-full bg-background px-6 py-12"
data-rakazo-surface="onboarding"
data-onboarding-step={step}
>
<OnboardingDiscoveryShell
items={discoveryItems}
compact={
step === "emoji" ||
step === "preferences" ||
step === "language" ||
step === "voice" ||
step === "integrations"
}
>
{error && step !== "bot" ? <p className="mb-4 text-sm text-destructive">{error}</p> : null}
{step === "emoji" ? (
<OnboardingOoDesigner
config={emojiConfig}
onChange={setEmojiConfig}
onContinue={() => setStep("preferences")}
/>
) : null}
{step === "preferences" ? (
<div className="mx-auto max-w-xl">
<h2 className="text-xl font-semibold tracking-tight text-foreground">
<Trans>Tell us about yourself</Trans>
</h2>
<p className="mt-1 text-sm text-muted-foreground">
<Trans>A few quick preferences so your Oo can be better tailored to you.</Trans>
</p>
<div className="mt-6">
<label
htmlFor={`${fieldId}-name`}
className="block text-sm font-medium text-foreground"
>
<Trans>Name</Trans>
</label>
<Input
id={`${fieldId}-name`}
value={prefs.name}
onChange={(e) => setPrefs({ ...prefs, name: e.target.value })}
placeholder={t`Your name`}
className="mt-2"
/>
</div>
<div className="mt-6">
<label
htmlFor={`${fieldId}-timezone`}
className="block text-sm font-medium text-foreground"
>
<Trans>Timezone</Trans>
</label>
<Input
id={`${fieldId}-timezone`}
value={prefs.timezone}
onChange={(e) => setPrefs({ ...prefs, timezone: e.target.value })}
placeholder={t`Auto-detected or enter manually`}
className="mt-2"
/>
</div>
<fieldset className="mt-6 border-0 p-0">
<legend className="block text-sm font-medium text-foreground">
<Trans>Hobbies & interests</Trans>
</legend>
<p className="mt-1 text-xs text-muted-foreground">
<Trans>Select all that apply</Trans>
</p>
<div className="mt-3 flex flex-wrap gap-2">
{HOBBY_OPTIONS.map((hobby) => (
<button
key={hobby.key}
type="button"
onClick={() => toggleHobby(hobby.key)}
aria-pressed={prefs.hobbies.includes(hobby.key)}
className={`rounded-full border px-4 py-1.5 text-sm font-medium transition-all ${
prefs.hobbies.includes(hobby.key)
? "border-primary bg-primary/10 text-foreground"
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
}`}
>
{hobby.label}
</button>
))}
</div>
</fieldset>
<fieldset className="mt-6 border-0 p-0">
<legend className="block text-sm font-medium text-foreground">
<Trans>Work style</Trans>
</legend>
<p className="mt-1 text-xs text-muted-foreground">
<Trans>How do you like to work?</Trans>
</p>
<div className="mt-3 grid grid-cols-2 gap-2">
{WORK_STYLE_OPTIONS.map((ws) => (
<button
key={ws.key}
type="button"
onClick={() => setPrefs({ ...prefs, workStyle: ws.key })}
aria-pressed={prefs.workStyle === ws.key}
className={`rounded-xl border-2 px-3 py-3 text-sm font-medium text-left transition-all ${
prefs.workStyle === ws.key
? "border-primary bg-primary/10 text-foreground shadow-sm"
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
}`}
>
{ws.label}
</button>
))}
</div>
</fieldset>
<fieldset className="mt-6 border-0 p-0">
<legend className="block text-sm font-medium text-foreground">
<Trans>Communication style</Trans>
</legend>
<p className="mt-1 text-xs text-muted-foreground">
<Trans>How should your Oo talk to you?</Trans>
</p>
<div className="mt-3 grid grid-cols-2 gap-2">
{COMMUNICATION_OPTIONS.map((comm) => (
<button
key={comm.key}
type="button"
onClick={() => setPrefs({ ...prefs, communication: comm.key })}
aria-pressed={prefs.communication === comm.key}
className={`rounded-xl border-2 px-3 py-3 text-sm font-medium text-left transition-all ${
prefs.communication === comm.key
? "border-primary bg-primary/10 text-foreground shadow-sm"
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
}`}
>
{comm.label}
</button>
))}
</div>
</fieldset>
<div className="mt-8 flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={() => setStep("emoji")}>
<Trans>Back</Trans>
</Button>
<Button onClick={() => setStep("language")}>
<Trans>Continue</Trans>
</Button>
</div>
</div>
) : null}
{step === "language" ? (
<div className="mx-auto max-w-xl">
<h2 className="text-xl font-semibold tracking-tight text-foreground">
<Trans>Choose your language</Trans>
</h2>
<p className="mt-1 text-sm text-muted-foreground">
<Trans>Your Oo will communicate in this language. You can change it later.</Trans>
</p>
<div className="mt-6 grid grid-cols-2 gap-2">
{Object.entries(UI_LOCALE_LABELS).map(([code, label]) => (
<button
key={code}
type="button"
onClick={() => {
persistUiLocale(code as UiLocale);
setCurrentLocale(code);
setStep("voice");
}}
className={`rounded-xl border-2 px-3 py-3 text-sm font-medium text-left transition-all ${
currentLocale === code
? "border-primary bg-primary/10 text-foreground shadow-sm"
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
}`}
>
{label}
</button>
))}
</div>
<div className="mt-8 flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={() => setStep("preferences")}>
<Trans>Back</Trans>
</Button>
<Button onClick={() => setStep("voice")}>
<Trans>Skip · English</Trans>
</Button>
</div>
</div>
) : null}
{step === "voice" ? (
<OnboardingVoiceStep
selectedVoiceId={selectedVoiceId}
onSelectedVoiceIdChange={setSelectedVoiceId}
onBack={() => setStep("language")}
onSkip={() => goAfterVoice(true)}
onContinue={() => goAfterVoice(false)}
/>
) : null}
{step === "integrations" ? (
<div>
<h2 className="mb-4 text-lg font-medium text-foreground">
<Trans>Connect tools when you''re ready</Trans>
</h2>
<IntegrationSetup
serverSetup
initialState={integrationSetup}
onDone={() => setStep("scan")}
onServerConnected={(id) =>
setIntegrationServers((current) => [...new Set([...current, id])])
}
/>
</div>
) : null}
{step === "scan" ? (
<OnboardingScanProgress
onComplete={() => setStep("bot")}
botId={scanBotId ?? undefined}
/>
) : null}
{step === "bot" && error ? (
<div className="text-center">
<p className="text-sm text-destructive">{error}</p>
<Button className="mt-4" onClick={() => void createFirstBot()}>
<Trans>Try again</Trans>
</Button>
</div>
) : null}
</OnboardingDiscoveryShell>
</div>
);
}