Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions app/src/components/onboarding/intro-animation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { type ReactNode, useEffect, useState } from "react";

export type IntroScene = { id: string; label: string; content: ReactNode };

/** A looping introduction instead of more wizard steps. */
export function IntroAnimation({ scenes }: { scenes: IntroScene[] }) {
const reducedMotion = useReducedMotion();
const [scene, setScene] = useState(0);
useEffect(() => {
if (reducedMotion || scenes.length < 2) return;
const timer = window.setInterval(
() => setScene((current) => (current + 1) % scenes.length),
4000,
);
return () => window.clearInterval(timer);
}, [reducedMotion, scenes.length]);

// Keep every scene available without auto-updating content when motion is reduced.
if (reducedMotion) {
return (
<section
aria-label="Meet OpenBot"
className="flex w-full flex-col gap-12 py-8"
>
{scenes.map((item) => (
<div key={item.id}>{item.content}</div>
))}
</section>
);
}

return (
<section aria-label="Meet OpenBot" className="w-full">
<div className="grid min-h-[440px] items-center">
<AnimatePresence initial={false} mode="wait">
<motion.div
key={scenes[scene]?.id}
initial={{ opacity: 0, y: reducedMotion ? 0 : 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reducedMotion ? 0 : -12 }}
transition={{ duration: reducedMotion ? 0 : 0.35 }}
className="col-start-1 row-start-1 w-full"
>
{scenes[scene]?.content}
</motion.div>
</AnimatePresence>
</div>
</section>
);
}
123 changes: 95 additions & 28 deletions app/src/routes/_authed/onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -265,11 +266,39 @@ function OrganizationStep() {
);
}

const STEPS: Array<(onNext: () => void) => React.ReactNode> = [
() => <WelcomeStep />,
() => <ComputerUseStep />,
() => <RosterStep />,
];
function Introduction({ localEvaluation }: { localEvaluation: boolean }) {
return (
<IntroAnimation
scenes={[
{ id: "welcome", label: "Welcome", content: <WelcomeStep /> },
{
id: "computer",
label: "Each Bot has its own computer",
content: <ComputerUseStep />,
},
{
id: "roster",
label: "Pick a Bot or make your own",
content: <RosterStep />,
},
...(localEvaluation
? [
{
id: "local",
label: "It runs on your machine",
content: <LocalStep />,
},
{
id: "agents",
label: "Bring the agents you already have",
content: <AgentsStep />,
},
]
: []),
]}
/>
);
}

/** A pane arrives from the side the journey is moving toward, and leaves out the other. */
const variants = {
Expand All @@ -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.
Expand All @@ -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<void>) => React.ReactNode> = [
...STEPS,
...(capabilities?.localEvaluation
? [() => <LocalStep />, () => <AgentsStep />, () => <OrganizationStep />]
: []),
(onFinish) => <OnboardingNameStep onSaved={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);
};

Expand Down Expand Up @@ -351,38 +381,75 @@ function RouteComponent() {
key={step}
variants={variants}
>
{steps[step]?.(async () => {
await complete.mutateAsync(undefined);
setLeaving(true);
})}
{step === "intro" && (
<Introduction localEvaluation={localEvaluation} />
)}
{step === "organization" && <OrganizationStep />}
{step === "name" && (
<OnboardingNameStep
onSaved={async () => {
await complete.mutateAsync(undefined);
setLeaving(true);
}}
/>
)}
</motion.div>
</AnimatePresence>

{capabilitiesError && !capabilities ? (
<div className="mt-4 text-center">
<p className="text-sm text-destructive" role="alert">
We couldn't load your setup. Try again to continue.
</p>
<Button
variant="ghost"
disabled={loadingCapabilities}
onClick={() => retryCapabilities()}
>
Try again
</Button>
</div>
) : null}

{complete.error ? (
<p className="mt-4 text-destructive text-sm" role="alert">
{complete.error.message}
</p>
) : null}

<motion.div
className={`${last ? "mt-4" : "mt-20"} flex flex-col items-center justify-center max-w-xs gap-4 w-full mx-auto`}
className={`${last ? "mt-4" : "mt-8"} flex flex-col items-center justify-center max-w-xs gap-4 w-full mx-auto`}
layout
>
{!last && (
<Button
className="w-full"
disabled={complete.isPending}
onClick={() => go(step + 1)}
disabled={complete.isPending || !capabilities}
onClick={() =>
go(
step === "intro" && localEvaluation
? "organization"
: "name",
true,
)
}
size="lg"
>
Continue
</Button>
)}
{step !== 0 && (
{step !== "intro" && (
<Button
className="w-full"
disabled={complete.isPending}
onClick={() => go(step - 1)}
onClick={() =>
go(
step === "name" && localEvaluation
? "organization"
: "intro",
false,
)
}
variant="secondary"
size="lg"
>
Expand Down
Loading