diff --git a/src/features/home/ui/WidgetCanvas.tsx b/src/features/home/ui/WidgetCanvas.tsx
index d0dd3deba..33024ed02 100644
--- a/src/features/home/ui/WidgetCanvas.tsx
+++ b/src/features/home/ui/WidgetCanvas.tsx
@@ -320,7 +320,7 @@ export function WidgetCanvas({
onOpenSkills,
onOpenAutomations,
onStartOnboardingTour,
- onStartChatWithPrompt,
+ onResolveBerdyAgent,
}: WidgetCanvasProps) {
const { t } = useTranslation("home");
const resolvedRecenterLabel =
@@ -735,7 +735,7 @@ export function WidgetCanvas({
onOpenSkills={onOpenSkills}
onOpenAutomations={onOpenAutomations}
onStartOnboardingTour={onStartOnboardingTour}
- onStartChatWithPrompt={onStartChatWithPrompt}
+ onResolveBerdyAgent={onResolveBerdyAgent}
/>
{catalogEntry.hideResizeHandle ? null : (
diff --git a/src/features/home/widgets/OnboardingTourWidget.test.tsx b/src/features/home/widgets/OnboardingTourWidget.test.tsx
index 82e7f91d7..6a7f004b5 100644
--- a/src/features/home/widgets/OnboardingTourWidget.test.tsx
+++ b/src/features/home/widgets/OnboardingTourWidget.test.tsx
@@ -1,7 +1,14 @@
-import { render, screen, waitFor } from "@testing-library/react";
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { setHomePinLabelsAlwaysVisible } from "@/features/home/lib/homePinLabelPreference";
+import { useAgentStore } from "@/features/agents/stores/agentStore";
import type { WidgetRenderProps } from "./types";
import { OnboardingTourWidget } from "./OnboardingTourWidget";
@@ -16,6 +23,8 @@ vi.mock("@/shared/hooks/useAvatarSrc", () => ({
}),
}));
+const avatarMediaRenderMock = vi.hoisted(() => vi.fn());
+
vi.mock("@/shared/ui/avatar-media", () => ({
AvatarMedia: ({
alt,
@@ -25,15 +34,18 @@ vi.mock("@/shared/ui/avatar-media", () => ({
alt: string;
loadingStrategy: string;
playbackMode: string;
- }) => (
-
- ),
+ }) => {
+ avatarMediaRenderMock();
+ return (
+
+ );
+ },
}));
const baseProps: WidgetRenderProps = {
@@ -50,6 +62,20 @@ const baseProps: WidgetRenderProps = {
describe("OnboardingTourWidget", () => {
beforeEach(() => {
localStorage.clear();
+ avatarMediaRenderMock.mockClear();
+ useAgentStore.setState({
+ personas: [
+ {
+ id: "/Users/test/.agents/agents/berdy.md",
+ displayName: "Berdy",
+ avatar: "app-avatar:gloopies-14",
+ systemPrompt: "Help people use Berd.",
+ isBuiltin: false,
+ writable: true,
+ sourceProperties: { metadata: { berdBundled: true } },
+ },
+ ],
+ });
});
it("uses 14px type and grows a gloopy bubble from the avatar", () => {
@@ -85,7 +111,7 @@ describe("OnboardingTourWidget", () => {
"translate-y-2",
);
const bubble = screen
- .getByText("Welcome to Berd!")
+ .getByText("Welcome!")
.closest("[data-onboarding-tour-bubble]");
expect(bubble).toHaveClass("absolute", "bottom-24", "left-36", "text-sm");
@@ -108,18 +134,15 @@ describe("OnboardingTourWidget", () => {
bubble?.querySelector('[data-onboarding-tour-caret-dot="large"]'),
).toHaveClass("-bottom-4", "left-4", "size-8", "rounded-full");
expect(
- bubble?.querySelector('[data-onboarding-tour-connector-fillet="top"]'),
- ).toHaveClass("rounded-full");
- expect(
- bubble?.querySelector('[data-onboarding-tour-connector-fillet="bottom"]'),
- ).toHaveClass("rounded-full");
+ bubble?.querySelectorAll("[data-onboarding-tour-liquid-shadow] circle"),
+ ).toHaveLength(0);
expect(
bubble?.querySelector(".onboarding-tour-bubble-content"),
).not.toHaveClass(
"drop-shadow-[0_12px_18px_rgba(0,0,0,0.14)]",
"dark:drop-shadow-[0_12px_18px_rgba(0,0,0,0.32)]",
);
- expect(screen.getByText("Welcome to Berd!")).toHaveClass("pr-5");
+ expect(screen.getByText("Welcome!")).toHaveClass("pr-5");
expect(
bubble?.querySelector(".onboarding-tour-bubble-content"),
).not.toHaveClass("pr-10");
@@ -147,7 +170,7 @@ describe("OnboardingTourWidget", () => {
expect(label).not.toHaveClass("opacity-0", "group-hover:opacity-100");
});
- it("opens the tour without dismissing the welcome tooltip", async () => {
+ it("opens the tour and retires the welcome tooltip", async () => {
const user = userEvent.setup();
const onStartOnboardingTour = vi.fn();
const onRemoveWidget = vi.fn();
@@ -167,12 +190,16 @@ describe("OnboardingTourWidget", () => {
expect(onStartOnboardingTour).toHaveBeenCalledOnce();
expect(onUpdateState).not.toHaveBeenCalled();
expect(onRemoveWidget).not.toHaveBeenCalled();
- expect(screen.getByText("Welcome to Berd!")).toBeInTheDocument();
+ expect(screen.getByText("Welcome!")).toBeInTheDocument();
+
+ const completeTour = onStartOnboardingTour.mock.calls[0]?.[0];
+ act(() => completeTour?.());
+ expect(onUpdateState).toHaveBeenCalledWith({ welcomeDismissed: true });
});
- it("keeps Berdy and offers suggested questions after the welcome callout", async () => {
+ it("tags Berdy in the composer after the welcome callout", async () => {
const user = userEvent.setup();
- const onStartChatWithPrompt = vi.fn();
+ const onTagAgentInComposer = vi.fn();
render(
{
...baseProps.instance,
state: { welcomeDismissed: true },
}}
- onStartChatWithPrompt={onStartChatWithPrompt}
+ onTagAgentInComposer={onTagAgentInComposer}
/>,
);
- expect(screen.queryByText("Welcome to Berd!")).not.toBeInTheDocument();
+ expect(screen.queryByText("Welcome!")).not.toBeInTheDocument();
expect(screen.getByTestId("animated-berdy")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Ask Berdy" }));
- expect(screen.getByText("How can I help?")).toBeInTheDocument();
- const firstSuggestion = screen.getByRole("button", {
- name: /What can I use Berd for/,
- });
- expect(firstSuggestion).toHaveClass(
- "group",
- "w-full",
- "bg-transparent",
- "px-0",
- "py-1.5",
- );
- expect(firstSuggestion).not.toHaveClass("rounded-[10px]", "bg-muted/50");
- expect(firstSuggestion.querySelector("span")).toHaveClass(
- "group-hover:font-medium",
- "group-focus-visible:font-medium",
- "motion-reduce:transition-none",
- );
- expect(firstSuggestion.querySelector("svg")).toHaveClass(
- "size-3",
- "opacity-0",
- "transition-opacity",
- "duration-150",
- "ease-out",
- "group-hover:opacity-100",
- "group-focus-visible:opacity-100",
- "motion-reduce:transition-none",
- );
- expect(
- screen.getByRole("button", { name: "Close help" }).parentElement,
- ).toHaveClass("right-3");
- expect(
- screen.getByRole("button", { name: /How do I start a project/ }),
- ).toBeInTheDocument();
- expect(
- screen.getByRole("button", { name: /How do agents and skills work/ }),
- ).toBeInTheDocument();
- expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
- await user.click(
- screen.getByRole("button", { name: /How do I start a project/ }),
+ expect(onTagAgentInComposer).toHaveBeenCalledOnce();
+ expect(onTagAgentInComposer).toHaveBeenCalledWith(
+ expect.stringContaining("berdy.md"),
);
-
- expect(onStartChatWithPrompt).not.toHaveBeenCalled();
- expect(
- screen.queryByRole("status", { name: "Berdy is typing" }),
- ).not.toBeInTheDocument();
expect(screen.queryByText("How can I help?")).not.toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Back" })).toHaveAttribute(
- "data-onboarding-tour-back",
+ });
+
+ it("does not rerender the avatar as its drag position changes", () => {
+ const { rerender } = render(
+ ,
);
- expect(screen.getByRole("button", { name: "Back" })).toHaveClass(
- "font-medium",
- "leading-5",
+ expect(avatarMediaRenderMock).toHaveBeenCalledOnce();
+
+ rerender(
+ ,
);
- await waitFor(() => {
- expect(
- screen.queryByText("How do I start a project?"),
- ).not.toBeInTheDocument();
- });
- expect(
- await screen.findByText(
- "Projects keep related chats, files, and working folders together so agents have the right context.",
- {},
- { timeout: 2_000 },
- ),
- ).toBeInTheDocument();
-
- const followUpInput = screen.getByRole("textbox", {
- name: "Ask a follow-up",
- });
- expect(followUpInput.closest("[data-slot='input-group']")).toHaveClass(
- "border-transparent",
- "bg-muted/40",
- "shadow-none",
- "focus-within:!border-transparent",
- "dark:bg-muted/40",
+ rerender(
+ ,
);
- expect(
- followUpInput.closest("[data-onboarding-tour-response]"),
- ).not.toHaveClass("mr-3");
- await user.type(followUpInput, "Can you give me an example?");
- await user.click(screen.getByRole("button", { name: "Send follow-up" }));
- expect(onStartChatWithPrompt).toHaveBeenCalledWith(
- "How do I start a project?\n\nFollow-up: Can you give me an example?",
+
+ expect(avatarMediaRenderMock).toHaveBeenCalledOnce();
+ });
+
+ it("keeps Berdy disabled while personas are loading", () => {
+ useAgentStore.setState({ personas: [], personasLoading: true });
+ const { rerender } = render(
+ ,
);
- await waitFor(() => {
- expect(screen.queryByText("How can I help?")).not.toBeInTheDocument();
+
+ expect(screen.getByRole("button", { name: "Ask Berdy" })).toBeDisabled();
+
+ act(() => {
+ useAgentStore.setState({
+ personasLoading: false,
+ personas: [
+ {
+ id: "/Users/test/.agents/agents/berdy.md",
+ displayName: "Berdy",
+ avatar: "app-avatar:gloopies-14",
+ systemPrompt: "Help people use Berd.",
+ isBuiltin: false,
+ writable: true,
+ sourceProperties: { metadata: { berdBundled: true } },
+ },
+ ],
+ });
});
- expect(screen.getByTestId("animated-berdy")).toBeInTheDocument();
+ rerender(
+ ,
+ );
+
+ expect(screen.getByRole("button", { name: "Ask Berdy" })).toBeEnabled();
});
- it("reveals the composer when the user wants to ask something else", async () => {
+ it("repairs a missing Berdy persona before tagging it", async () => {
const user = userEvent.setup();
- const onStartChatWithPrompt = vi.fn();
+ const onResolveBerdyAgent = vi.fn().mockResolvedValue("repaired-berdy");
+ const onTagAgentInComposer = vi.fn();
+ useAgentStore.setState({ personas: [], personasLoading: false });
render(
{
...baseProps.instance,
state: { welcomeDismissed: true },
}}
- onStartChatWithPrompt={onStartChatWithPrompt}
+ onResolveBerdyAgent={onResolveBerdyAgent}
+ onTagAgentInComposer={onTagAgentInComposer}
/>,
);
await user.click(screen.getByRole("button", { name: "Ask Berdy" }));
- await user.click(
- screen.getByRole("button", { name: "Ask something else" }),
- );
- const input = await screen.findByRole("textbox", {
- name: "Ask Berdy anything",
- });
- const inputGroup = input.closest("[data-slot='input-group']");
- const sendButton = screen.getByRole("button", { name: "Send message" });
- expect(inputGroup).toHaveClass(
- "border-transparent",
- "bg-muted/40",
- "shadow-none",
- "focus-within:!border-transparent",
- "dark:bg-muted/40",
- );
- expect(sendButton.closest("[data-align='inline-end']")).toHaveClass(
- "ml-auto",
- );
- expect(sendButton.querySelector("svg")).toHaveClass("size-4");
- expect(screen.getByRole("button", { name: "Back" })).toHaveAttribute(
- "data-onboarding-tour-back",
- );
- expect(screen.getByRole("button", { name: "Back" })).toHaveClass(
- "font-medium",
- "leading-5",
- );
- expect(
- screen.getByRole("button", { name: "Back" }).querySelector("svg"),
- ).toHaveClass("lucide-chevron-left");
- await user.type(input, "How do I create a project?");
- await user.click(sendButton);
-
- expect(onStartChatWithPrompt).toHaveBeenCalledWith(
- "How do I create a project?",
- );
+ expect(onResolveBerdyAgent).toHaveBeenCalledOnce();
await waitFor(() => {
- expect(screen.queryByText("How can I help?")).not.toBeInTheDocument();
+ expect(onTagAgentInComposer).toHaveBeenCalledWith("repaired-berdy");
});
- expect(screen.getByTestId("animated-berdy")).toBeInTheDocument();
});
- it("keeps the question open when the Berdy chat cannot start", async () => {
- const user = userEvent.setup();
- const onStartChatWithPrompt = vi.fn(async () => false);
+ it("ignores a completed Berdy repair after unmount", async () => {
+ let resolveBerdy!: (personaId: string | null) => void;
+ const onResolveBerdyAgent = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveBerdy = resolve;
+ }),
+ );
+ const onTagAgentInComposer = vi.fn();
+ useAgentStore.setState({ personas: [], personasLoading: false });
- render(
+ const { unmount } = render(
,
);
- await user.click(screen.getByRole("button", { name: "Ask Berdy" }));
- await user.click(
- screen.getByRole("button", { name: "Ask something else" }),
- );
- const input = await screen.findByRole("textbox", {
- name: "Ask Berdy anything",
- });
- await user.type(input, "How do I create a project?");
- await user.click(screen.getByRole("button", { name: "Send message" }));
+ fireEvent.click(screen.getByRole("button", { name: "Ask Berdy" }));
+ unmount();
+ await act(async () => resolveBerdy("late-berdy"));
- expect(onStartChatWithPrompt).toHaveBeenCalledOnce();
- expect(input).toHaveValue("How do I create a project?");
- expect(screen.getByText("Back")).toBeInTheDocument();
+ expect(onTagAgentInComposer).not.toHaveBeenCalled();
});
- it("starts only one chat while a Berdy prompt is pending", async () => {
- const user = userEvent.setup();
- let resolveStart!: (didStart: boolean) => void;
- const onStartChatWithPrompt = vi.fn(
+ it("ignores a completed Berdy repair after onboarding resets", async () => {
+ let resolveBerdy!: (personaId: string | null) => void;
+ const onResolveBerdyAgent = vi.fn(
() =>
- new Promise((resolve) => {
- resolveStart = resolve;
+ new Promise((resolve) => {
+ resolveBerdy = resolve;
}),
);
+ const onTagAgentInComposer = vi.fn();
+ useAgentStore.setState({ personas: [], personasLoading: false });
- render(
+ const { rerender } = render(
,
);
- await user.click(screen.getByRole("button", { name: "Ask Berdy" }));
- await user.click(
- screen.getByRole("button", { name: "Ask something else" }),
- );
- const input = await screen.findByRole("textbox", {
- name: "Ask Berdy anything",
- });
- await user.type(input, "How do I create a project?");
- const sendButton = screen.getByRole("button", { name: "Send message" });
+ fireEvent.click(screen.getByRole("button", { name: "Ask Berdy" }));
+ rerender( );
+ await act(async () => resolveBerdy("late-berdy"));
- await user.click(sendButton);
- expect(sendButton).toBeDisabled();
- await user.click(sendButton);
- expect(onStartChatWithPrompt).toHaveBeenCalledOnce();
-
- resolveStart(true);
- await waitFor(() => {
- expect(screen.queryByText("How can I help?")).not.toBeInTheDocument();
- });
+ expect(onTagAgentInComposer).not.toHaveBeenCalled();
+ expect(screen.getByText("Welcome!")).toBeInTheDocument();
});
- it("collapses the help composer without removing Berdy", async () => {
+ it("contains a rejected Berdy repair and enables retry", async () => {
const user = userEvent.setup();
- const onRemoveWidget = vi.fn();
+ const onResolveBerdyAgent = vi
+ .fn()
+ .mockRejectedValueOnce(new Error("repair unavailable"))
+ .mockResolvedValueOnce("repaired-berdy");
+ const onTagAgentInComposer = vi.fn();
+ const consoleError = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+ useAgentStore.setState({ personas: [], personasLoading: false });
render(
{
...baseProps.instance,
state: { welcomeDismissed: true },
}}
- onRemoveWidget={onRemoveWidget}
+ onResolveBerdyAgent={onResolveBerdyAgent}
+ onTagAgentInComposer={onTagAgentInComposer}
/>,
);
- await user.click(screen.getByRole("button", { name: "Ask Berdy" }));
- await user.click(screen.getByRole("button", { name: "Close help" }));
+ const berdyButton = screen.getByRole("button", { name: "Ask Berdy" });
+ await user.click(berdyButton);
+ await waitFor(() => expect(berdyButton).toBeEnabled());
+ expect(onTagAgentInComposer).not.toHaveBeenCalled();
+ await user.click(berdyButton);
await waitFor(() => {
- expect(screen.queryByText("How can I help?")).not.toBeInTheDocument();
+ expect(onTagAgentInComposer).toHaveBeenCalledWith("repaired-berdy");
});
- expect(screen.getByTestId("animated-berdy")).toBeInTheDocument();
- expect(onRemoveWidget).not.toHaveBeenCalled();
+ consoleError.mockRestore();
});
- it("toggles the help bubble when Berdy is clicked again", async () => {
- const user = userEvent.setup();
- const onRemoveWidget = vi.fn();
+ it("disables Berdy when neither a persona nor resolver is available", () => {
+ useAgentStore.setState({ personas: [], personasLoading: false });
render(
{
...baseProps.instance,
state: { welcomeDismissed: true },
}}
- onRemoveWidget={onRemoveWidget}
/>,
);
- const berdy = screen.getByRole("button", { name: "Ask Berdy" });
- await user.click(berdy);
- expect(screen.getByText("How can I help?")).toBeInTheDocument();
-
- await user.click(berdy);
- await waitFor(() => {
- expect(screen.queryByText("How can I help?")).not.toBeInTheDocument();
- });
- expect(screen.getByTestId("animated-berdy")).toBeInTheDocument();
- expect(onRemoveWidget).not.toHaveBeenCalled();
+ expect(screen.getByRole("button", { name: "Ask Berdy" })).toBeDisabled();
});
it("dismisses only the welcome tooltip from its close control", async () => {
@@ -494,7 +463,7 @@ describe("OnboardingTourWidget", () => {
);
await waitFor(() => {
- expect(screen.queryByText("Welcome to Berd!")).not.toBeInTheDocument();
+ expect(screen.queryByText("Welcome!")).not.toBeInTheDocument();
});
expect(screen.getByTestId("animated-berdy")).toBeInTheDocument();
});
diff --git a/src/features/home/widgets/OnboardingTourWidget.tsx b/src/features/home/widgets/OnboardingTourWidget.tsx
index 38747c37e..6dbc30f89 100644
--- a/src/features/home/widgets/OnboardingTourWidget.tsx
+++ b/src/features/home/widgets/OnboardingTourWidget.tsx
@@ -1,12 +1,20 @@
-import { useEffect, useId, useRef, useState, type FormEvent } from "react";
+import {
+ memo,
+ useEffect,
+ useId,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "react";
import { useTranslation } from "react-i18next";
-import { ArrowRight, ChevronLeft, X } from "lucide-react";
+import { X } from "lucide-react";
import {
AnimatePresence,
motion,
useMotionValue,
useReducedMotion,
useSpring,
+ type MotionValue,
} from "motion/react";
import { selectAvatarImageUrl } from "@/shared/api/artifacts";
import { useHomePinLabelsPreference } from "@/features/home/lib/homePinLabelPreference";
@@ -15,12 +23,8 @@ import { useAvatarMedia } from "@/shared/hooks/useAvatarSrc";
import { cn } from "@/shared/lib/cn";
import { AvatarMedia } from "@/shared/ui/avatar-media";
import { Button } from "@/shared/ui/button";
-import {
- InputGroup,
- InputGroupAddon,
- InputGroupButton,
- InputGroupInput,
-} from "@/shared/ui/input-group";
+import { useAgentStore } from "@/features/agents/stores/agentStore";
+import { findBerdyPersonaId } from "@/features/onboarding/berdyAgent";
import type { WidgetRenderProps } from "./types";
import { useWidgetActivationGuard } from "./useWidgetActivationGuard";
@@ -31,29 +35,6 @@ const SWAY_X_SPRING = { stiffness: 110, damping: 12, mass: 0.85 };
const SWAY_Y_SPRING = { stiffness: 190, damping: 24, mass: 0.7 };
const SWAY_ROTATION_SPRING = { stiffness: 140, damping: 16, mass: 0.7 };
-const HELP_PRESET_KEYS = ["useCases", "projects", "agentsAndSkills"] as const;
-type HelpPresetKey = (typeof HELP_PRESET_KEYS)[number];
-
-function OnboardingBackButton({
- label,
- onClick,
-}: {
- label: string;
- onClick: () => void;
-}) {
- return (
-
-
- {label}
-
- );
-}
-
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
@@ -67,26 +48,8 @@ function directionLockedVelocity(velocity: { x: number; y: number }) {
return { x, y };
}
-export function OnboardingTourWidget({
- instance,
- onUpdateState,
- shouldIgnoreActivation,
- onStartOnboardingTour,
- onStartChatWithPrompt,
- canvasDragPosition,
-}: WidgetRenderProps) {
- const { t } = useTranslation("home");
- const { enabled: alwaysShowLabel } = useHomePinLabelsPreference();
+function useBerdySway(canvasDragPosition?: { x: number; y: number }) {
const shouldReduceMotion = useReducedMotion();
- const bubbleShadowId = `berdy-bubble-shadow-${useId().replace(/:/g, "")}`;
- const [isBubbleSettled, setIsBubbleSettled] = useState(false);
- const [helpOpen, setHelpOpen] = useState(false);
- const [composerOpen, setComposerOpen] = useState(false);
- const [activePreset, setActivePreset] = useState(null);
- const [prompt, setPrompt] = useState("");
- const [isStartingChat, setIsStartingChat] = useState(false);
- const startChatInFlightRef = useRef(false);
- const welcomeDismissed = instance.state?.welcomeDismissed === true;
const lastDragPositionRef = useRef<{
x: number;
y: number;
@@ -99,88 +62,7 @@ export function OnboardingTourWidget({
const swayX = useSpring(swayTargetX, SWAY_X_SPRING);
const swayY = useSpring(swayTargetY, SWAY_Y_SPRING);
const swayRotate = useSpring(swayTargetRotate, SWAY_ROTATION_SPRING);
- const gloopyPoster = useArtifacts({
- select: (artifacts) => selectAvatarImageUrl(artifacts, "gloopies-14"),
- });
- const gloopyMedia = useAvatarMedia("app-avatar:gloopies-14");
- const start = useWidgetActivationGuard(shouldIgnoreActivation, () => {
- onStartOnboardingTour?.();
- });
- const toggleHelp = useWidgetActivationGuard(shouldIgnoreActivation, () => {
- if (welcomeDismissed) {
- if (helpOpen) {
- setHelpOpen(false);
- setPrompt("");
- setComposerOpen(false);
- setActivePreset(null);
- return;
- }
- setIsBubbleSettled(false);
- setPrompt("");
- setComposerOpen(false);
- setActivePreset(null);
- setHelpOpen(true);
- }
- });
-
- const startChat = async (text: string) => {
- if (!onStartChatWithPrompt || startChatInFlightRef.current) {
- return;
- }
- startChatInFlightRef.current = true;
- setIsStartingChat(true);
- try {
- const didStart = await onStartChatWithPrompt(text);
- if (didStart === false) {
- return;
- }
- setPrompt("");
- setComposerOpen(false);
- setActivePreset(null);
- setHelpOpen(false);
- } finally {
- startChatInFlightRef.current = false;
- setIsStartingChat(false);
- }
- };
-
- const showPresetResponse = (key: HelpPresetKey) => {
- setPrompt("");
- setComposerOpen(false);
- setActivePreset(key);
- };
-
- const returnToSuggestions = () => {
- setPrompt("");
- setComposerOpen(false);
- setActivePreset(null);
- };
-
- const handleSubmit = (event: FormEvent) => {
- event.preventDefault();
- event.stopPropagation();
- const text = prompt.trim();
- if (!text) {
- return;
- }
- void startChat(text);
- };
-
- const handlePresetFollowUp = (event: FormEvent) => {
- event.preventDefault();
- event.stopPropagation();
- const followUp = prompt.trim();
- if (!activePreset || !followUp) {
- return;
- }
- void startChat(
- t("onboarding.callout.followUpContext", {
- question: t(`onboarding.callout.presets.${activePreset}`),
- followUp,
- }),
- );
- };
useEffect(() => {
const settle = () => {
swayTargetX.set(0);
@@ -198,9 +80,7 @@ export function OnboardingTourWidget({
const timestamp = performance.now();
const lastPosition = lastDragPositionRef.current;
lastDragPositionRef.current = { ...canvasDragPosition, timestamp };
- if (!lastPosition) {
- return;
- }
+ if (!lastPosition) return;
const elapsed = clamp(timestamp - lastPosition.timestamp, 8, 40);
const frameScale = 1000 / 60 / elapsed;
@@ -216,8 +96,6 @@ export function OnboardingTourWidget({
dragVelocityRef.current = velocity;
const directionalVelocity = directionLockedVelocity(velocity);
- // The bubble trails behind Berdy's direction of travel, like a mass held
- // by the speech-bubble caret, then preserves that velocity in the spring.
swayTargetX.set(clamp(-directionalVelocity.x * 1.6, -22, 22));
swayTargetY.set(clamp(-directionalVelocity.y * 0.55, -8, 8));
swayTargetRotate.set(clamp(-directionalVelocity.x * 0.5, -8, 8));
@@ -232,6 +110,124 @@ export function OnboardingTourWidget({
swayTargetY,
]);
+ return { swayX, swayY, swayRotate };
+}
+
+export function OnboardingTourWidget({
+ instance,
+ onUpdateState,
+ shouldIgnoreActivation,
+ onStartOnboardingTour,
+ onOpenAgent,
+ onTagAgentInComposer,
+ onResolveBerdyAgent,
+ canvasDragPosition,
+}: WidgetRenderProps) {
+ const sway = useBerdySway(canvasDragPosition);
+
+ return (
+
+ );
+}
+
+interface BerdyContentProps {
+ welcomeDismissed: boolean;
+ onUpdateState: WidgetRenderProps["onUpdateState"];
+ shouldIgnoreActivation: WidgetRenderProps["shouldIgnoreActivation"];
+ onStartOnboardingTour: WidgetRenderProps["onStartOnboardingTour"];
+ onOpenAgent: WidgetRenderProps["onOpenAgent"];
+ onTagAgentInComposer: WidgetRenderProps["onTagAgentInComposer"];
+ onResolveBerdyAgent: WidgetRenderProps["onResolveBerdyAgent"];
+ swayX: MotionValue;
+ swayY: MotionValue;
+ swayRotate: MotionValue;
+}
+
+const BerdyContent = memo(function BerdyContent({
+ welcomeDismissed,
+ onUpdateState,
+ shouldIgnoreActivation,
+ onStartOnboardingTour,
+ onOpenAgent,
+ onTagAgentInComposer,
+ onResolveBerdyAgent,
+ swayX,
+ swayY,
+ swayRotate,
+}: BerdyContentProps) {
+ const { t } = useTranslation("home");
+ const { enabled: alwaysShowLabel } = useHomePinLabelsPreference();
+ const shouldReduceMotion = useReducedMotion();
+ const bubbleShadowId = `berdy-bubble-shadow-${useId().replace(/:/g, "")}`;
+ const [isBubbleSettled, setIsBubbleSettled] = useState(false);
+ const [isResolvingBerdy, setIsResolvingBerdy] = useState(false);
+ const mountedRef = useRef(true);
+ const resolveAttemptRef = useRef(0);
+ const personas = useAgentStore((state) => state.personas);
+ const personasLoading = useAgentStore((state) => state.personasLoading);
+ const berdyPersonaId = findBerdyPersonaId(personas);
+ const gloopyPoster = useArtifacts({
+ select: (artifacts) => selectAvatarImageUrl(artifacts, "gloopies-14"),
+ });
+ const gloopyMedia = useAvatarMedia("app-avatar:gloopies-14");
+ const start = useWidgetActivationGuard(shouldIgnoreActivation, () => {
+ onStartOnboardingTour?.(() => {
+ onUpdateState({ welcomeDismissed: true });
+ });
+ });
+ const activateBerdy = useWidgetActivationGuard(shouldIgnoreActivation, () => {
+ if (!welcomeDismissed || isResolvingBerdy) return;
+ if (berdyPersonaId) {
+ (onTagAgentInComposer ?? onOpenAgent)?.(berdyPersonaId);
+ return;
+ }
+ if (!onResolveBerdyAgent) return;
+
+ const attempt = ++resolveAttemptRef.current;
+ setIsResolvingBerdy(true);
+ void onResolveBerdyAgent()
+ .then((resolvedPersonaId) => {
+ if (!mountedRef.current || resolveAttemptRef.current !== attempt)
+ return;
+ if (resolvedPersonaId) {
+ (onTagAgentInComposer ?? onOpenAgent)?.(resolvedPersonaId);
+ }
+ })
+ .catch((error: unknown) => {
+ console.error("Failed to resolve the bundled Berdy agent:", error);
+ })
+ .finally(() => {
+ if (mountedRef.current && resolveAttemptRef.current === attempt) {
+ setIsResolvingBerdy(false);
+ }
+ });
+ });
+
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ resolveAttemptRef.current += 1;
+ };
+ }, []);
+
+ useLayoutEffect(() => {
+ if (!welcomeDismissed) {
+ resolveAttemptRef.current += 1;
+ setIsResolvingBerdy(false);
+ setIsBubbleSettled(false);
+ }
+ }, [welcomeDismissed]);
+
return (
@@ -240,8 +236,13 @@ export function OnboardingTourWidget({
data-onboarding-tour-avatar=""
className="pointer-events-auto relative z-10 size-full cursor-pointer overflow-visible border-0 bg-transparent p-0 drop-shadow-[0_12px_12px_rgba(0,0,0,0.05)] outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default"
aria-label={t("onboarding.callout.openHelp")}
- disabled={!welcomeDismissed}
- onClick={toggleHelp}
+ disabled={
+ !welcomeDismissed ||
+ personasLoading ||
+ isResolvingBerdy ||
+ (!berdyPersonaId && !onResolveBerdyAgent)
+ }
+ onClick={activateBerdy}
>
{gloopyMedia ? (
- {!welcomeDismissed || helpOpen ? (
+ {!welcomeDismissed ? (
- {welcomeDismissed ? (
-
- {activePreset || composerOpen ? (
-
- ) : (
-
- {t("onboarding.callout.helpTitle")}
-
- )}
-
- {composerOpen ? (
-
-
- setPrompt(event.target.value)}
- placeholder={t(
- "onboarding.callout.helpPlaceholder",
- )}
- aria-label={t("onboarding.callout.helpPlaceholder")}
- autoFocus
- />
-
-
-
-
-
-
-
- ) : activePreset ? (
-
-
-
- {t(
- `onboarding.callout.presetResponses.${activePreset}`,
- )}
-
-
-
-
- ) : (
-
- {HELP_PRESET_KEYS.map((key) => {
- const question = t(
- `onboarding.callout.presets.${key}`,
- );
- return (
- showPresetResponse(key)}
- >
-
- {question}
-
-
-
- );
- })}
- setComposerOpen(true)}
- >
-
- {t("onboarding.callout.askSomethingElse")}
-
-
-
-
- )}
-
-
- ) : (
- <>
-
- {t("onboarding.callout.title")}
-
-
- {t("onboarding.callout.body")}
-
-
- {t("onboarding.callout.action")}
-
- >
- )}
+
+ {t("onboarding.callout.title")}
+
+ {t("onboarding.callout.body")}
+
+ {t("onboarding.callout.action")}
+
{
event.stopPropagation();
- if (welcomeDismissed) {
- setComposerOpen(false);
- setActivePreset(null);
- setHelpOpen(false);
- } else {
- onUpdateState({ welcomeDismissed: true });
- }
+ onUpdateState({ welcomeDismissed: true });
}}
>
@@ -659,4 +476,4 @@ export function OnboardingTourWidget({
);
-}
+});
diff --git a/src/features/home/widgets/types.ts b/src/features/home/widgets/types.ts
index a8a43ae1d..d8d021cf7 100644
--- a/src/features/home/widgets/types.ts
+++ b/src/features/home/widgets/types.ts
@@ -69,10 +69,8 @@ export interface WidgetRenderProps {
onCreateProject?: () => void;
onOpenSkills?: () => void;
onOpenAutomations?: () => void;
- onStartOnboardingTour?: () => void;
- onStartChatWithPrompt?: (
- prompt: string,
- ) => boolean | undefined | Promise;
+ onStartOnboardingTour?: (onComplete?: () => void) => void;
+ onResolveBerdyAgent?: () => Promise;
onRemoveWidget?: () => void;
/** True while this widget is being dragged or resized on the home canvas. */
canvasGestureActive?: boolean;
@@ -109,10 +107,8 @@ export interface WidgetCatalogEntry {
}
export interface WidgetNavigationHandlers {
- onStartOnboardingTour?: () => void;
- onStartChatWithPrompt?: (
- prompt: string,
- ) => boolean | undefined | Promise;
+ onStartOnboardingTour?: (onComplete?: () => void) => void;
+ onResolveBerdyAgent?: () => Promise;
onOpenProject?: (projectId: string) => void;
onOpenSkill?: (skill: SkillInfo) => void;
onOpenAgent?: (agentId: string) => void;
diff --git a/src/features/onboarding/assets/tour-4-agents.png b/src/features/onboarding/assets/tour-4-agents.png
new file mode 100644
index 000000000..c5cc416fb
Binary files /dev/null and b/src/features/onboarding/assets/tour-4-agents.png differ
diff --git a/src/features/onboarding/ui/OnboardingTourDialog.test.tsx b/src/features/onboarding/ui/OnboardingTourDialog.test.tsx
index 6f0d4a033..12a1fc176 100644
--- a/src/features/onboarding/ui/OnboardingTourDialog.test.tsx
+++ b/src/features/onboarding/ui/OnboardingTourDialog.test.tsx
@@ -9,23 +9,6 @@ vi.mock("@/shared/theme/ThemeProvider", () => ({
useTheme: () => themeState,
}));
-vi.mock("@/features/projects/artifact/ProjectArtifactPreview", () => ({
- ProjectArtifactPreview: ({
- input,
- motionImpulse,
- }: {
- input: { name: string; prompt: string };
- motionImpulse?: { sequence: number };
- }) => (
-
- {input.name}: {input.prompt}
-
- ),
-}));
-
describe("OnboardingTourDialog", () => {
beforeEach(() => {
themeState.isDark = false;
@@ -43,11 +26,18 @@ describe("OnboardingTourDialog", () => {
).toContain("tour-1-dark.png");
});
- it("advances through four steps and finishes", async () => {
+ it("advances through five steps and finishes", async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn();
-
- render( );
+ const onComplete = vi.fn();
+
+ render(
+ ,
+ );
expect(screen.getByRole("dialog")).toHaveClass("dark:bg-card");
expect(document.querySelector("[data-onboarding-tour-copy]")).toHaveClass(
@@ -57,7 +47,7 @@ describe("OnboardingTourDialog", () => {
expect(
screen.getByRole("heading", { name: "Your canvas, your home" }),
).toBeInTheDocument();
- expect(screen.getByText("1 of 4")).toBeInTheDocument();
+ expect(screen.getByText("1 of 5")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Next" })).toHaveClass(
"bg-accent",
"rounded-[10px]",
@@ -98,7 +88,7 @@ describe("OnboardingTourDialog", () => {
);
await user.click(nextButton);
const secondStepHeading = screen.getByRole("heading", {
- name: "Chat with Berd about anything",
+ name: "All your AI providers in one place",
});
expect(secondStepHeading).toHaveFocus();
expect(artwork).toContainElement(
@@ -114,6 +104,15 @@ describe("OnboardingTourDialog", () => {
screen.queryByRole("button", { name: "Previous tour step" }),
).not.toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Next tour step" }));
+ expect(
+ document.querySelectorAll("[data-onboarding-tour-provider]"),
+ ).toHaveLength(5);
+ expect(screen.getByText("Amp")).toHaveClass("sr-only");
+ expect(
+ document.querySelector("[data-onboarding-tour-provider]"),
+ ).toHaveClass("size-24");
+
await user.click(screen.getByRole("button", { name: "Next tour step" }));
expect(
document.querySelectorAll("[data-onboarding-tour-chat-bubble]"),
@@ -122,42 +121,65 @@ describe("OnboardingTourDialog", () => {
document.querySelector("[data-onboarding-tour-chat-bubble]")
?.parentElement,
).toHaveClass("px-16");
-
await user.click(screen.getByRole("button", { name: "Next tour step" }));
expect(
- document.querySelectorAll("[data-onboarding-tour-provider]"),
- ).toHaveLength(5);
- expect(screen.getByText("Amp")).toHaveClass("sr-only");
- expect(
- document.querySelector("[data-onboarding-tour-provider]"),
- ).toHaveClass("size-24");
+ screen.getByRole("heading", { name: "Agents have joined the chat" }),
+ ).toBeInTheDocument();
+ const agentsImage = document.querySelector(
+ "[data-onboarding-tour-agents-image]",
+ );
+ expect(agentsImage?.getAttribute("src")).toContain("tour-4-agents.png");
+ expect(agentsImage).toHaveClass("object-contain", "max-h-[270px]");
+
await user.click(screen.getByRole("button", { name: "Next tour step" }));
expect(
- screen.getByRole("heading", { name: "Bring your projects to life" }),
+ screen.getByRole("heading", {
+ name: "Teach Berd a new trick with skills",
+ }),
).toBeInTheDocument();
- const projectCube = screen.getByRole("button", {
- name: "Spin project cube",
- });
- await user.click(projectCube);
- expect(screen.getByTestId("tour-project-preview")).toHaveAttribute(
- "data-motion-sequence",
- "1",
+ const skillPills = document.querySelectorAll(
+ "[data-onboarding-tour-skill]",
);
- expect(screen.getByTestId("tour-project-preview")).toHaveTextContent(
- "Your project: A place for chats, files, context, and ongoing work.",
+ expect(skillPills).toHaveLength(5);
+ expect(screen.getByText("research")).toHaveClass(
+ "rounded-full",
+ "text-[1.625rem]",
+ "smooth-shadow-sm",
);
+ expect(screen.getByText("writing")).toBeInTheDocument();
+ expect(screen.getByText("planning")).toBeInTheDocument();
+ expect(screen.getByText("code-search")).toBeInTheDocument();
+ expect(screen.getByText("summarize")).toBeInTheDocument();
+ expect(screen.queryByText("goose-help")).not.toBeInTheDocument();
+ expect(skillPills[0]?.parentElement).toHaveClass(
+ "top-1/2",
+ "left-8",
+ "-translate-y-1/2",
+ "items-start",
+ );
+ expect(
+ screen.queryByRole("button", { name: "Next tour step" }),
+ ).not.toBeInTheDocument();
const doneButton = screen.getByRole("button", { name: "Done" });
expect(doneButton).toHaveClass("bg-accent", "rounded-[10px]", "text-sm");
await user.click(doneButton);
+ expect(onComplete).toHaveBeenCalledOnce();
expect(onOpenChange).toHaveBeenCalledWith(false);
});
- it("provides a close button", async () => {
+ it("does not complete the tour from its close button", async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn();
-
- render( );
+ const onComplete = vi.fn();
+
+ render(
+ ,
+ );
const closeButton = screen.getByRole("button", { name: "Close tour" });
expect(closeButton).toHaveAttribute("data-slot", "dialog-close");
@@ -168,5 +190,6 @@ describe("OnboardingTourDialog", () => {
await user.click(closeButton);
expect(onOpenChange).toHaveBeenCalledWith(false);
+ expect(onComplete).not.toHaveBeenCalled();
});
});
diff --git a/src/features/onboarding/ui/OnboardingTourDialog.tsx b/src/features/onboarding/ui/OnboardingTourDialog.tsx
index 47a0d3422..4851b0a18 100644
--- a/src/features/onboarding/ui/OnboardingTourDialog.tsx
+++ b/src/features/onboarding/ui/OnboardingTourDialog.tsx
@@ -5,12 +5,13 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import tourTexture from "../assets/texture.png";
import tourHomeImage from "../assets/tour-1.png";
import tourHomeDarkImage from "../assets/tour-1-dark.png";
-import { ProjectArtifactPreview } from "@/features/projects/artifact/ProjectArtifactPreview";
-import type {
- ProjectArtifactInput,
- ProjectArtifactMotionImpulse,
-} from "@/features/projects/artifact/types";
+import tourAgentsImage from "../assets/tour-4-agents.png";
import { Button } from "@/shared/ui/button";
+import {
+ resolveSkillPillTone,
+ skillPillToneClass,
+} from "@/features/skills/lib/resolveSkillPillTone";
+import { cn } from "@/shared/lib/cn";
import { useTheme } from "@/shared/theme/ThemeProvider";
import { GlassButton } from "@/shared/ui/glass-button";
import {
@@ -28,30 +29,19 @@ import {
OpenAIIcon,
} from "@/shared/ui/icons/ProviderIcons";
-const TOUR_STEP_COUNT = 4;
+const TOUR_STEP_COUNT = 5;
const PROVIDER_EASE = [0.16, 1, 0.3, 1] as const;
-const TOUR_PROJECT_BASE: Omit = {
- projectId: "onboarding-tour-project",
- color: "blue",
- workingDirs: ["project"],
- sessionCount: 6,
- artifact: {
- seed: 28,
- color: "blue",
- mood: "active",
- moodIntensity: 0.72,
- contentMode: "cubeStatic",
- },
-};
interface OnboardingTourDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
+ onComplete?: () => void;
}
export function OnboardingTourDialog({
open,
onOpenChange,
+ onComplete,
}: OnboardingTourDialogProps) {
const { t } = useTranslation("home");
const [step, setStep] = useState(0);
@@ -69,6 +59,7 @@ export function OnboardingTourDialog({
const advance = () => {
if (step === TOUR_STEP_COUNT - 1) {
+ onComplete?.();
onOpenChange(false);
return;
}
@@ -117,7 +108,9 @@ export function OnboardingTourDialog({
>
{t(`onboarding.tour.steps.${step + 1}.title`)}
-
+
{t(`onboarding.tour.steps.${step + 1}.body`)}
{step === 0 ? : null}
- {step === 1 ? : null}
- {step === 2 ? : null}
- {step === 3 ? : null}
+ {step === 1 ? : null}
+ {step === 2 ? : null}
+ {step === 3 ? : null}
+ {step === 4 ? : null}
{t(`onboarding.tour.steps.${step + 1}.visual`)}
@@ -181,19 +175,17 @@ function TourArtwork({ step, onBack, onAdvance }: TourArtworkProps) {
) : null}
-
-
-
+ {step < TOUR_STEP_COUNT - 1 ? (
+
+
+
+ ) : null}
);
}
@@ -354,38 +346,50 @@ function ProviderPreview() {
);
}
-function ProjectPreview() {
- const { t } = useTranslation("home");
- const shouldReduceMotion = useReducedMotion();
- const [motionImpulse, setMotionImpulse] =
- useState