* 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>
567 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|