* 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>
373 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|