evOo-local/apps/web/src/pages/Onboarding.tsx
cynarAI 97787a0272
Production readiness phase 1: journey audit, fixes 1–4, E2E (#175)
* Production readiness phase 1: journeys doc, onboarding persistence, teardown, chat UX

- Add docs/PRODUCTION-READINESS.md with journey matrix and defect backlog
- Persist onboarding draft in session storage across reload
- Localize onboarding discovery main-bot role; German Chief of Staff string
- Destroy team sandboxes and home folders when deleting an account
- Keep composer/transcript visible during queued runs; extend send watchdog
- Add Playwright specs: reload persistence, DE intake, queued progress, console guard

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

* Fix CI: sync Lingui catalogs, onboarding name draft, E2E stability

- Localize Chief of Staff in discovery shell; sync all locale PO files
- Propagate Oo name to onboarding draft while typing
- Adjust DE intake, queued-run, and console-error E2E specs

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

* Format chat-queued-progress E2E spec

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

* fix(api): scope account-delete sandbox teardown to owned spaces

Only destroy team sandboxes and homes for spaces the deleting user
owns with no remaining members or bots. Guard home removal under
dataDir/homes. Add unit and postgres journey coverage.

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

* chore: apply biome formatting

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-10-07 11:25:39 +02:00

373 lines
13 KiB
TypeScript

import { Trans, useLingui } from "@lingui/react/macro";
import type { IntegrationSetupState, MainConversationRef } from "@rakazo/contracts";
import { Button } from "@rakazo/ui-web";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { IntegrationSetup } from "../components/integrations/IntegrationSetup";
import { OnboardingDiscoveryShell } from "../components/onboarding/OnboardingDiscoveryShell";
import {
DEFAULT_FIRST_OO_NAME,
OnboardingNameStep,
} from "../components/onboarding/OnboardingNameStep";
import {
type OnboardingEmojiConfig,
OnboardingOoDesigner,
} from "../components/onboarding/OnboardingOoDesigner";
import { OnboardingScanStep } from "../components/onboarding/OnboardingScanStep";
import {
KOKORO_LOCAL_TOKEN,
OnboardingVoiceStep,
} from "../components/onboarding/OnboardingVoiceStep";
import { localTimezone } from "../lib/local-timezone";
import {
computeDiscoveryItems,
countConfiguredIntegrations,
type OnboardingDiscoveryPhase,
} from "../lib/onboarding-discovery";
import {
clearOnboardingDraft,
persistOnboardingDraft,
readOnboardingDraft,
} from "../lib/onboarding-draft";
import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-look-categories";
import { onboardingProfileUpdate } from "../lib/onboarding-profile";
import { rpc, selectSpace } from "../lib/rpc";
import {
persistUiLocale,
resolveUiLocale,
UI_LOCALE_LABELS,
type UiLocale,
} from "../lib/ui-locale";
import { collectLocalProfileSignals, persistUserProfileHarvest } from "../lib/user-profile-harvest";
/** Selects the main conversation's space and returns its route. */
function enterMainConversation(main: MainConversationRef): string {
selectSpace(main.spaceId);
return `/app/g/${main.groupId}`;
}
function defaultEmojiConfig(): OnboardingEmojiConfig {
const preset = findLookCategory("calm").looks[0];
return {
pose: preset?.pose ?? "idle",
avatarValue: preset ? lookPresetToAvatarValue(preset) : "oo1:c=8B5CF6",
};
}
export function OnboardingPage() {
const { t } = useLingui();
const navigate = useNavigate();
const [step, setStep] = useState<
"loading" | "emoji" | "name" | "language" | "voice" | "integrations" | "scan" | "bot"
>("emoji");
const [ooName, setOoName] = useState(DEFAULT_FIRST_OO_NAME);
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 [emojiConfig, setEmojiConfig] = useState<OnboardingEmojiConfig>(defaultEmojiConfig);
const [currentLocale, setCurrentLocale] = useState<UiLocale>(() => resolveUiLocale());
const [languageChoice, setLanguageChoice] = useState<UiLocale | null>(null);
const activeUiLocale = languageChoice ?? currentLocale;
const draftRestoredRef = useRef(false);
useEffect(() => {
if (draftRestoredRef.current) return;
draftRestoredRef.current = true;
const draft = readOnboardingDraft();
if (!draft || draft.step === "bot") return;
setStep(draft.step);
setEmojiConfig(draft.emojiConfig);
setOoName(draft.ooName);
setLanguageChoice(draft.languageChoice);
setCurrentLocale(draft.currentLocale);
setSelectedVoiceId(draft.selectedVoiceId);
setVoiceSkipped(draft.voiceSkipped);
}, []);
useEffect(() => {
if (step === "loading" || step === "bot") return;
persistOnboardingDraft({
step,
emojiConfig,
ooName,
languageChoice,
currentLocale,
selectedVoiceId,
voiceSkipped,
});
}, [step, emojiConfig, ooName, languageChoice, currentLocale, selectedVoiceId, voiceSkipped]);
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);
setIntegrationSetup(integrations);
// The first step renders before this resolves; never pull a user back who moved on.
if (!me.needsModel && integrations?.needsSetup) {
setStep((current) => (current === "emoji" ? "integrations" : current));
}
// Onboarding runs once: a user with a main bot, or a space that already
// has a bot, does not need the designer again. 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 (navigation.main && !me.needsModel) {
navigate(enterMainConversation(navigation.main), { replace: true });
} else if (existing && !me.needsModel) {
navigate(`/app/${existing.id}`, { replace: true });
}
} catch {
// Best effort; fresh signups stay on onboarding.
}
})
.catch(() => {
if (!cancelled) setStep((current) => (current === "loading" ? "emoji" : current));
});
return () => {
cancelled = true;
};
}, [navigate]);
const discoveryPhase: OnboardingDiscoveryPhase =
step === "loading"
? "loading"
: step === "emoji" || step === "name" || 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 rpc.bots.ensureMain();
const trimmedName = ooName.trim() || DEFAULT_FIRST_OO_NAME;
await rpc.bots
.update({ botId: bot.id, color: emojiConfig.avatarValue, name: trimmedName })
.catch(() => null);
setCrewReady(true);
await rpc.profile
.update(
onboardingProfileUpdate({
browserTimeZone: localTimezone(),
locale: typeof navigator !== "undefined" ? navigator.language : undefined,
language: languageChoice,
}),
)
.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);
await persistUserProfileHarvest(rpc, collectLocalProfileSignals(me ?? undefined)).catch(
() => null,
);
setProfilePersisted(true);
for (const serverId of integrationServers) {
await rpc.mcp.assignments.approve({ botId: bot.id, serverId });
}
// The main bot asks the rest in the main conversation.
await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined);
const main = await rpc.spaces
.list()
.then((navigation) => navigation.main)
.catch(() => null);
clearOnboardingDraft();
navigate(main ? enterMainConversation(main) : `/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 === "name" ||
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("name")}
/>
) : null}
{step === "name" ? (
<OnboardingNameStep
config={emojiConfig}
name={ooName}
onNameChange={setOoName}
onBack={() => setStep("emoji")}
onContinue={() => setStep("language")}
/>
) : 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 as UiLocale);
setLanguageChoice(code as UiLocale);
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("name")}>
<Trans>Back</Trans>
</Button>
<Button
onClick={() => {
const locale = resolveUiLocale();
setLanguageChoice(locale);
setCurrentLocale(locale);
setStep("voice");
}}
>
{t`Skip · ${UI_LOCALE_LABELS[activeUiLocale]}`}
</Button>
</div>
</div>
) : null}
{step === "voice" ? (
<OnboardingVoiceStep
uiLocale={activeUiLocale}
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" ? <OnboardingScanStep onDone={() => setStep("bot")} /> : 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>
);
}