From 7dd37e49bd3b75fc80e46c1553427ece22132390 Mon Sep 17 00:00:00 2001 From: Sam Julien Date: Wed, 28 Oct 2026 08:31:28 -0700 Subject: [PATCH 1/2] feat(onboarding): consolidate introduction into animated overview --- .../components/onboarding/intro-animation.tsx | 76 +++++++++++ app/src/routes/_authed/onboarding.tsx | 123 ++++++++++++++---- 2 files changed, 171 insertions(+), 28 deletions(-) create mode 100644 app/src/components/onboarding/intro-animation.tsx diff --git a/app/src/components/onboarding/intro-animation.tsx b/app/src/components/onboarding/intro-animation.tsx new file mode 100644 index 0000000000..4d6964b774 --- /dev/null +++ b/app/src/components/onboarding/intro-animation.tsx @@ -0,0 +1,76 @@ +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { type ReactNode, useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; + +export type IntroScene = { id: string; label: string; content: ReactNode }; + +/** One introduction, with optional playback controls instead of more wizard steps. */ +export function IntroAnimation({ scenes }: { scenes: IntroScene[] }) { + const reducedMotion = useReducedMotion(); + const [scene, setScene] = useState(0); + const [paused, setPaused] = useState(false); + const last = scene === scenes.length - 1; + const playing = !reducedMotion && !paused && !last; + + useEffect(() => { + if (!playing) return; + const timer = window.setTimeout(() => setScene(scene + 1), 6000); + return () => window.clearTimeout(timer); + }, [playing, scene]); + + return ( +
+
+ + + {scenes[scene]?.content} + + +
+
+
+ {scenes.map((item, index) => ( + + ))} +
+ {!reducedMotion && ( + + )} +
+
+ ); +} diff --git a/app/src/routes/_authed/onboarding.tsx b/app/src/routes/_authed/onboarding.tsx index 4319ebee72..0cf102c45f 100644 --- a/app/src/routes/_authed/onboarding.tsx +++ b/app/src/routes/_authed/onboarding.tsx @@ -12,6 +12,7 @@ import { DesktopIllustration } from "@/components/computer/desktop-illustration" import { ComputerPlaceholder } from "@/components/computer/placeholder"; import { SELF_HOST_URL } from "@/components/layout/self-host-banner"; import { IllustrationFrame } from "@/components/onboarding/illustration-frame"; +import { IntroAnimation } from "@/components/onboarding/intro-animation"; import { LaptopIllustration } from "@/components/onboarding/laptop-illustration"; import { OrganizationFlow } from "@/components/onboarding/organization-flow"; import { OnboardingNameStep } from "@/components/settings/profile"; @@ -169,7 +170,7 @@ function RosterStep() { } /* - * THE THREE SLIDES BELOW ARE FOR SOMEBODY TRYING OPENBOT ON THEIR OWN MACHINE, and only they see them + * These local-evaluation scenes are for somebody trying OpenBot on their own machine * (see `localEvaluation` in RouteComponent). They answer what that person is deciding: where their * data is, whether their own agents fit, and what taking it to their organization involves (OB-02 in * the product backlog). None of it is said to an organization's own people, who are past all three. @@ -265,11 +266,39 @@ function OrganizationStep() { ); } -const STEPS: Array<(onNext: () => void) => React.ReactNode> = [ - () => , - () => , - () => , -]; +function Introduction({ localEvaluation }: { localEvaluation: boolean }) { + return ( + }, + { + id: "computer", + label: "Each Bot has its own computer", + content: , + }, + { + id: "roster", + label: "Pick a Bot or make your own", + content: , + }, + ...(localEvaluation + ? [ + { + id: "local", + label: "It runs on your machine", + content: , + }, + { + id: "agents", + label: "Bring the agents you already have", + content: , + }, + ] + : []), + ]} + /> + ); +} /** A pane arrives from the side the journey is moving toward, and leaves out the other. */ const variants = { @@ -283,14 +312,16 @@ function RouteComponent() { const complete = useMutation(completeOnboardingMutationOptions(queryClient)); // Browser state on purpose: the step is not persisted while the wizard is being designed. - const [step, setStep] = React.useState(0); + const [step, setStep] = React.useState<"intro" | "organization" | "name">( + "intro", + ); const [direction, setDirection] = React.useState(1); // The way out: set once the completion is saved, it fades the whole page and then navigates. const [leaving, setLeaving] = React.useState(false); const [ref, bounds] = useMeasure(); /* - * The three local-evaluation slides are never shown to an organization's people: + * Local-evaluation content is never shown to an organization's people: * the server says single-user mode, which an organization's deployment, always behind sign-in, * cannot be in. Until the answer arrives they are left out, so a slow or failed request never * shows an organization's employees a page about deploying OpenBot themselves. @@ -299,18 +330,17 @@ function RouteComponent() { * Intelligence entitlement, and the stack `copilotkit local` installs runs on an enterprise * evaluation licence, so every CLI evaluator, the person these slides are for, would never see them. */ - const { data: capabilities } = useQuery(deploymentCapabilitiesQueryOptions()); - const steps: Array<(onFinish: () => Promise) => React.ReactNode> = [ - ...STEPS, - ...(capabilities?.localEvaluation - ? [() => , () => , () => ] - : []), - (onFinish) => , - ]; - const last = step === steps.length - 1; + const { + data: capabilities, + error: capabilitiesError, + refetch: retryCapabilities, + isFetching: loadingCapabilities, + } = useQuery(deploymentCapabilitiesQueryOptions()); + const localEvaluation = capabilities?.localEvaluation === true; + const last = step === "name"; - const go = (to: number) => { - setDirection(to > step ? 1 : -1); + const go = (to: typeof step, forward: boolean) => { + setDirection(forward ? 1 : -1); setStep(to); }; @@ -351,13 +381,36 @@ function RouteComponent() { key={step} variants={variants} > - {steps[step]?.(async () => { - await complete.mutateAsync(undefined); - setLeaving(true); - })} + {step === "intro" && ( + + )} + {step === "organization" && } + {step === "name" && ( + { + await complete.mutateAsync(undefined); + setLeaving(true); + }} + /> + )} + {capabilitiesError && !capabilities ? ( +
+

+ We couldn't load your setup. Try again to continue. +

+ +
+ ) : null} + {complete.error ? (

{complete.error.message} @@ -365,24 +418,38 @@ function RouteComponent() { ) : null} {!last && ( )} - {step !== 0 && ( + {step !== "intro" && ( - ))} - - {!reducedMotion && ( - - )} - ); }