diff --git a/src/app/AppShell.navigation.test.tsx b/src/app/AppShell.navigation.test.tsx index 1ddd50226..1dbee4cf1 100644 --- a/src/app/AppShell.navigation.test.tsx +++ b/src/app/AppShell.navigation.test.tsx @@ -573,7 +573,7 @@ vi.mock("./ui/AppShellContent", () => ({ onTagHomeComposerSkill, onSelectSession, onStartProjectChat, - onStartChatWithPrompt, + onResolveBerdyAgent, }) => { const starterTasks = useStarterTasks(); const activeView = targetLocation.view; @@ -625,7 +625,11 @@ vi.mock("./ui/AppShellContent", () => ({ @@ -2882,7 +2886,7 @@ describe("AppShell global navigation", () => { }); }); - it("starts Berdy help prompts with the bundled Berdy persona", async () => { + it("resolves the bundled Berdy persona for Home", async () => { const personaId = "/Users/test/.agents/agents/berdy.md"; useAgentStore.setState({ personas: [ @@ -2904,24 +2908,11 @@ describe("AppShell global navigation", () => { ); await waitFor(() => { - expect(useChatStore.getState().queuedMessageBySession).toMatchObject({ - "created-session": [ - { - payload: { - text: "How do projects work?", - persona: { kind: "persona", id: personaId }, - showInComposer: false, - }, - }, - ], - }); - expect( - useChatSessionStore.getState().getSession("created-session"), - ).toMatchObject({ personaId }); + expect(screen.getByText("Berdy")).toBeInTheDocument(); }); }); - it("restores a missing bundled Berdy agent before starting a chat", async () => { + it("restores a missing bundled Berdy agent before tagging it", async () => { const personaId = "/Users/test/.agents/agents/berdy.md"; mockListPersonas.mockResolvedValue([ { @@ -2943,16 +2934,11 @@ describe("AppShell global navigation", () => { await waitFor(() => { expect(mockRepairBundledAgent).toHaveBeenCalledWith("berdy.md"); - expect( - useChatSessionStore.getState().getSession("created-session"), - ).toMatchObject({ personaId }); + expect(screen.getByText("Berdy")).toBeInTheDocument(); }); expect(mockRepairBundledAgent.mock.invocationCallOrder[0]).toBeLessThan( mockListPersonas.mock.invocationCallOrder[0], ); - expect(mockListPersonas.mock.invocationCallOrder[0]).toBeLessThan( - mockAcpCreateSession.mock.invocationCallOrder[0], - ); expect(mockToastError).not.toHaveBeenCalledWith( "Berdy couldn't start a chat. Try again.", ); @@ -2981,9 +2967,7 @@ describe("AppShell global navigation", () => { await waitFor(() => { expect(mockListPersonas).toHaveBeenCalled(); - expect( - useChatSessionStore.getState().getSession("created-session"), - ).toMatchObject({ personaId }); + expect(screen.getByText("Berdy")).toBeInTheDocument(); }); }); diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index ef28b6dc4..507906097 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -2986,8 +2986,10 @@ export function AppShell({ ], ); - const handleStartChatWithBerdy = useCallback( - async (text: string): Promise => { + const handleResolveBerdyAgent = useCallback(async (): Promise< + string | null + > => { + try { const store = useAgentStore.getState(); let personaId = findBerdyPersonaId(store.personas); @@ -3027,19 +3029,16 @@ export function AppShell({ if (!personaId) { toast.error(t("home:onboarding.callout.agentUnavailable")); - return false; + return null; } - return new Promise((resolve) => { - handleGlobalCompose( - text, - { personaId }, - { showQueuedHandoff: false, onSettled: resolve }, - ); - }); - }, - [handleGlobalCompose, t], - ); + return personaId; + } catch (error) { + console.error("Failed to resolve the bundled Berdy agent:", error); + toast.error(t("home:onboarding.callout.agentUnavailable")); + return null; + } + }, [t]); const handleGlobalComposerExpand = useCallback( (payload: GlobalComposerExpandPayload): Promise => { @@ -5001,7 +5000,7 @@ export function AppShell({ onStartChatFromProject={handleStartChatFromProject} onStartProjectChat={handleStartProjectChat} onStartChatWithSkill={handleStartChatWithSkill} - onStartChatWithPrompt={handleStartChatWithBerdy} + onResolveBerdyAgent={handleResolveBerdyAgent} onExitSearch={handleExitSearch} onOpenExtension={handleOpenExtensionFromSearch} onOpenAgent={handleStartChatWithAgent} diff --git a/src/app/ui/AppShellContent.tsx b/src/app/ui/AppShellContent.tsx index 3233c97c0..6cc075964 100644 --- a/src/app/ui/AppShellContent.tsx +++ b/src/app/ui/AppShellContent.tsx @@ -102,9 +102,7 @@ interface AppShellContentProps { onStartChatFromProject: (project: ProjectInfo) => void; onStartProjectChat: (projectId: string) => void; onStartChatWithSkill: (skill: SkillInfo, projectId?: string | null) => void; - onStartChatWithPrompt: ( - prompt: string, - ) => boolean | undefined | Promise; + onResolveBerdyAgent: () => Promise; onExitSearch: () => void; onOpenExtension: (entry: ExtensionEntry) => void; onOpenAgent: (agentId: string) => void; @@ -166,7 +164,7 @@ export function AppShellContent({ onStartChatFromProject, onStartProjectChat, onStartChatWithSkill, - onStartChatWithPrompt, + onResolveBerdyAgent, onExitSearch, onOpenExtension, onOpenAgent, @@ -221,7 +219,7 @@ export function AppShellContent({ onOpenSkills={() => onNavigateSkills(null)} onOpenAutomations={openHomeAutomations} onHydratePinnedChatSessions={onHydratePinnedChatSessions} - onStartChatWithPrompt={onStartChatWithPrompt} + onResolveBerdyAgent={onResolveBerdyAgent} viewportLeftOcclusionPx={homeViewportLeftOcclusionPx} /> ); diff --git a/src/features/design-system/ui/designSystemSections.ts b/src/features/design-system/ui/designSystemSections.ts index 29873e9a8..050f7b1a3 100644 --- a/src/features/design-system/ui/designSystemSections.ts +++ b/src/features/design-system/ui/designSystemSections.ts @@ -116,7 +116,6 @@ export const DESIGN_SYSTEM_COMPONENT_SECTIONS: Array<{ { id: "component-context-menu", label: "Context Menu" }, { id: "component-file-context-menu", label: "File Context Menu" }, { id: "component-image-lightbox", label: "Image Lightbox" }, - { id: "component-input-group", label: "Input Group" }, { id: "component-input", label: "Input" }, { id: "component-label", label: "Label" }, { id: "component-detail-page-shell", label: "Detail Page Shell" }, @@ -162,6 +161,7 @@ export const DESIGN_SYSTEM_UNUSED_COMPONENT_SECTIONS: Array<{ { id: "component-form", label: "Form" }, { id: "component-berd-logo", label: "Berd Logo" }, { id: "component-hover-card", label: "Hover Card" }, + { id: "component-input-group", label: "Input Group" }, { id: "component-input-otp", label: "Input OTP" }, { id: "component-menubar", label: "Menubar" }, { id: "component-navigation-menu", label: "Navigation Menu" }, diff --git a/src/features/experiments/ExperimentsSettings.tsx b/src/features/experiments/ExperimentsSettings.tsx index e16113f72..aa46eb17c 100644 --- a/src/features/experiments/ExperimentsSettings.tsx +++ b/src/features/experiments/ExperimentsSettings.tsx @@ -61,7 +61,9 @@ export function ExperimentsSettings({ const visibleRegistry = useMemo( () => getVisibleExperimentRegistry(registry).filter( - (definition) => !HIDDEN_EXPERIMENT_IDS.has(definition.id), + (definition) => + !HIDDEN_EXPERIMENT_IDS.has(definition.id) && + (definition.settingsVisibility !== "dev" || import.meta.env.DEV), ), [registry], ); diff --git a/src/features/experiments/__tests__/ExperimentsSettings.test.tsx b/src/features/experiments/__tests__/ExperimentsSettings.test.tsx index 24ee60fc5..e24446a8e 100644 --- a/src/features/experiments/__tests__/ExperimentsSettings.test.tsx +++ b/src/features/experiments/__tests__/ExperimentsSettings.test.tsx @@ -180,6 +180,32 @@ describe("ExperimentsSettings", () => { expect(resetHomeForOnboardingExperienceMock).toHaveBeenCalledOnce(); }); + it("hides onboarding experiment controls outside dev builds", () => { + vi.stubEnv("DEV", false); + renderWithProviders(); + + expect( + screen.queryByText( + i18n.t("experiments.starterTasks.title", { ns: "settings" }), + ), + ).not.toBeInTheDocument(); + expect( + screen.queryByText( + i18n.t("experiments.berdyOnboarding.title", { ns: "settings" }), + ), + ).not.toBeInTheDocument(); + expect( + screen.queryByText( + i18n.t("experiments.firstRunOnboarding.title", { ns: "settings" }), + ), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("region", { + name: i18n.t("experiments.onboarding.title", { ns: "settings" }), + }), + ).not.toBeInTheDocument(); + }); + it("preserves first-run state when reset-all preparation fails", async () => { vi.stubEnv("DEV", true); resetHomeForOnboardingExperienceMock.mockResolvedValueOnce(false); @@ -230,8 +256,8 @@ describe("ExperimentsSettings", () => { expect(resetOnboardingTourExperienceMock).toHaveBeenCalledOnce(); }); - it("syncs Berdy onboarding when its experiment is toggled", async () => { - vi.stubEnv("DEV", false); + it("syncs Berdy onboarding when its dev-only experiment is toggled", async () => { + vi.stubEnv("DEV", true); const user = userEvent.setup(); renderWithProviders(); @@ -243,7 +269,7 @@ describe("ExperimentsSettings", () => { }), ); - expect(syncOnboardingExperimentStateMock).toHaveBeenCalledWith(true); + expect(syncOnboardingExperimentStateMock).toHaveBeenCalledWith(false); }); it("does not advertise the retired macOS 26 voice requirement", () => { diff --git a/src/features/experiments/experimentDefinitions.ts b/src/features/experiments/experimentDefinitions.ts index 931ad77da..0a84cea5b 100644 --- a/src/features/experiments/experimentDefinitions.ts +++ b/src/features/experiments/experimentDefinitions.ts @@ -43,6 +43,8 @@ export interface ExperimentDefinition { defaultEnabled?: boolean; /** Opt-out of development's global experiment auto-enable behavior. */ manualEnableOnly?: boolean; + /** Limit this experiment's Settings controls without changing runtime state. */ + settingsVisibility?: "all" | "dev"; config?: Record; } @@ -98,6 +100,7 @@ export const EXPERIMENT_DEFINITIONS = [ id: STARTER_TASKS_EXPERIMENT_ID, titleKey: "experiments.starterTasks.title", descriptionKey: "experiments.starterTasks.description", + settingsVisibility: "dev", }, { id: VOICE_CONVERSATION_EXPERIMENT_ID, @@ -119,6 +122,7 @@ export const EXPERIMENT_DEFINITIONS = [ id: BERDY_ONBOARDING_EXPERIMENT_ID, titleKey: "experiments.berdyOnboarding.title", descriptionKey: "experiments.berdyOnboarding.description", + settingsVisibility: "dev", }, { id: FIRST_RUN_ONBOARDING_EXPERIMENT_ID, @@ -126,5 +130,6 @@ export const EXPERIMENT_DEFINITIONS = [ descriptionKey: "experiments.firstRunOnboarding.description", defaultEnabled: false, manualEnableOnly: true, + settingsVisibility: "dev", }, ] as const satisfies readonly ExperimentDefinition[]; diff --git a/src/features/home/ui/HomeView.tsx b/src/features/home/ui/HomeView.tsx index 9364edab6..e87cbe53f 100644 --- a/src/features/home/ui/HomeView.tsx +++ b/src/features/home/ui/HomeView.tsx @@ -69,9 +69,7 @@ export interface HomeViewProps { onCreateProject?: () => void; onOpenSkills?: () => void; onOpenAutomations?: () => void; - onStartChatWithPrompt?: ( - prompt: string, - ) => boolean | undefined | Promise; + onResolveBerdyAgent?: () => Promise; onHydratePinnedChatSessions?: (sessionIds: string[]) => void; viewportLeftOcclusionPx?: number; } @@ -90,7 +88,7 @@ export function HomeView({ onCreateProject, onOpenSkills, onOpenAutomations, - onStartChatWithPrompt, + onResolveBerdyAgent, onHydratePinnedChatSessions, viewportLeftOcclusionPx = 0, }: HomeViewProps) { @@ -112,6 +110,7 @@ export function HomeView({ const starterLayoutArrangementAttemptedRef = useRef(false); const [tourOpen, setTourOpen] = useState(false); + const tourCompleteRef = useRef<(() => void) | null>(null); const berdyOnboardingExperiment = useExperiment( BERDY_ONBOARDING_EXPERIMENT_ID, ); @@ -565,12 +564,19 @@ export function HomeView({ return () => window.removeEventListener("keydown", handleReloadOnboarding); }, [reloadOnboardingTourForDev]); - const handleStartTour = useCallback(() => { + const handleStartTour = useCallback((onComplete?: () => void) => { + tourCompleteRef.current = onComplete ?? null; setTourOpen(true); }, []); const handleTourOpenChange = useCallback((open: boolean) => { setTourOpen(open); + if (!open) tourCompleteRef.current = null; + }, []); + + const handleTourComplete = useCallback(() => { + tourCompleteRef.current?.(); + tourCompleteRef.current = null; }, []); useEffect(() => { @@ -688,12 +694,13 @@ export function HomeView({ onOpenSkills={onOpenSkills} onOpenAutomations={onOpenAutomations} onStartOnboardingTour={handleStartTour} - onStartChatWithPrompt={onStartChatWithPrompt} + onResolveBerdyAgent={onResolveBerdyAgent} /> ) : null} {loadStatus === "loading" ? (
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 : ( - ); -} - 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}`, - )} -

-
- - - setPrompt(event.target.value) - } - placeholder={t( - "onboarding.callout.followUpPlaceholder", - )} - aria-label={t( - "onboarding.callout.followUpPlaceholder", - )} - /> - - - - - - -
-
-
- ) : ( - - {HELP_PRESET_KEYS.map((key) => { - const question = t( - `onboarding.callout.presets.${key}`, - ); - return ( - - ); - })} - - - )} -
-
- ) : ( - <> -

- {t("onboarding.callout.title")} -

-

- {t("onboarding.callout.body")} -

- - - )} +

+ {t("onboarding.callout.title")} +

+

{t("onboarding.callout.body")}

+
{ 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`)}
); } @@ -354,38 +346,50 @@ function ProviderPreview() { ); } -function ProjectPreview() { - const { t } = useTranslation("home"); - const shouldReduceMotion = useReducedMotion(); - const [motionImpulse, setMotionImpulse] = - useState(); +function AgentsPreview() { + return ( +
+ +
+ ); +} - const animateProject = () => { - if (shouldReduceMotion) return; - setMotionImpulse((current) => ({ - sequence: (current?.sequence ?? 0) + 1, - deltaX: 0.26, - deltaY: -0.08, - })); - }; +function SkillsPreview() { + const shouldReduceMotion = useReducedMotion(); + const skills = [ + "research", + "writing", + "planning", + "code-search", + "summarize", + ]; return ( - +
+ {skills.map((skill, index) => ( + + {skill} + + ))} +
); } diff --git a/src/shared/i18n/locales/en/home.json b/src/shared/i18n/locales/en/home.json index 25a394736..dbf4743fd 100644 --- a/src/shared/i18n/locales/en/home.json +++ b/src/shared/i18n/locales/en/home.json @@ -26,39 +26,18 @@ "avatarAlt": "Berdy, your onboarding buddy", "agentUnavailable": "Berdy couldn't start a chat. Try again.", "dismiss": "Dismiss onboarding", - "title": "Welcome to Berd!", - "body": "I’m Berdy – your onboarding buddy. Try getting started by taking a tour of Berd.", + "title": "Welcome!", + "body": "I’m Berdy – your built-in Berd assistant. I’m here to help you get stuff done.", "action": "Take a tour", - "openHelp": "Ask Berdy", - "helpTitle": "How can I help?", - "presets": { - "useCases": "What can I use Berd for?", - "projects": "How do I start a project?", - "agentsAndSkills": "How do agents and skills work?" - }, - "presetResponses": { - "useCases": "Berd can help you research, plan, write, code, organize projects, and automate repeatable work.", - "projects": "Projects keep related chats, files, and working folders together so agents have the right context.", - "agentsAndSkills": "Agents give Berd a role and instructions. Skills add reusable workflows and tools for specific kinds of work." - }, - "followUpPlaceholder": "Ask a follow-up", - "sendFollowUp": "Send follow-up", - "followUpContext": "{{question}}\n\nFollow-up: {{followUp}}", - "askSomethingElse": "Ask something else", - "back": "Back", - "helpPlaceholder": "Ask Berdy anything", - "send": "Send message", - "closeHelp": "Close help" + "openHelp": "Ask Berdy" }, "tour": { "progress": "{{current}} of {{total}}", "previous": "Previous tour step", "next": "Next tour step", - "finish": "Finish tour", "close": "Close tour", "nextAction": "Next", "doneAction": "Done", - "animateProject": "Spin project cube", "chatPreview": { "prompt": "Can you help me write a haiku about birds?", "responseLine1": "Birds stitch dawn with song", @@ -66,30 +45,31 @@ "responseLine3": "Morning learns to fly", "typing": "Berd is typing" }, - "projectPreview": { - "name": "Your project", - "description": "A place for chats, files, context, and ongoing work." - }, "steps": { "1": { "title": "Your canvas, your home", - "body": "Keep the people, projects, and work you care about together on a canvas that is yours.", + "body": "Pin what matters, find it fast. Home is where you keep the stuff you don't want to dig for — pin a chat, an agent, a widget.", "visual": "A preview of the Berd home canvas" }, "2": { - "title": "Chat with Berd about anything", - "body": "Ask questions, explore ideas, and work through a task with an agent that has the right context.", - "visual": "A short conversation with Berd" + "title": "All your AI providers in one place", + "body": "Whichever provider you use, you can chat with it here. Connect more than one and switch between them anytime, even mid-chat.", + "visual": "A collection of AI provider icons" }, "3": { - "title": "Connect with all your providers", - "body": "Bring the models and providers you already use into one place and choose the right one for each task.", - "visual": "A collection of AI provider icons" + "title": "Ask Berd anything", + "body": "Ask questions, explore ideas, or work through a task. Berd keeps the context so you can keep going.", + "visual": "A short conversation with Berd" }, "4": { - "title": "Bring your projects to life", - "body": "Group chats, files, and working directories around real efforts so Berd can help you keep moving.", - "visual": "A project brought to life" + "title": "Agents have joined the chat", + "body": "An agent is a specialist you talk to directly. @mention one in a chat and it works with you like a teammate, not a tool.", + "visual": "A collection of Berd agents" + }, + "5": { + "title": "Teach Berd a new trick with skills", + "body": "Skills give Berd and your agents instructions for a specific task, tool, or workflow, so you don't have to spell it out every time.", + "visual": "A collection of reusable Berd skills" } } } diff --git a/src/shared/i18n/locales/es/home.json b/src/shared/i18n/locales/es/home.json index a083804e1..b586f3daa 100644 --- a/src/shared/i18n/locales/es/home.json +++ b/src/shared/i18n/locales/es/home.json @@ -26,39 +26,18 @@ "avatarAlt": "Berdy, tu guía de introducción", "agentUnavailable": "Berdy no pudo iniciar un chat. Inténtalo de nuevo.", "dismiss": "Cerrar introducción", - "title": "¿No sabes cómo empezar?", - "body": "Soy Berdy, tu guía de introducción. Pregúntame cualquier cosa sobre Berd, desde cómo empezar hasta aprender más sobre los flujos de trabajo.", + "title": "¡Te damos la bienvenida!", + "body": "Soy Berdy, tu asistente integrado de Berd. Estoy aquí para ayudarte a avanzar.", "action": "Ver el recorrido", - "openHelp": "Preguntarle a Berdy", - "helpTitle": "¿Cómo puedo ayudarte?", - "presets": { - "useCases": "¿Para qué puedo usar Berd?", - "projects": "¿Cómo inicio un proyecto?", - "agentsAndSkills": "¿Cómo funcionan los agentes y las habilidades?" - }, - "presetResponses": { - "useCases": "Berd puede ayudarte a investigar, planificar, escribir, programar, organizar proyectos y automatizar tareas repetitivas.", - "projects": "Los proyectos reúnen chats, archivos y carpetas de trabajo relacionados para que los agentes tengan el contexto adecuado.", - "agentsAndSkills": "Los agentes le dan a Berd un rol e instrucciones. Las habilidades añaden flujos de trabajo y herramientas reutilizables." - }, - "followUpPlaceholder": "Haz una pregunta de seguimiento", - "sendFollowUp": "Enviar seguimiento", - "followUpContext": "{{question}}\n\nSeguimiento: {{followUp}}", - "askSomethingElse": "Hacer otra pregunta", - "back": "Atrás", - "helpPlaceholder": "Pregúntale cualquier cosa a Berdy", - "send": "Enviar mensaje", - "closeHelp": "Cerrar ayuda" + "openHelp": "Preguntarle a Berdy" }, "tour": { "progress": "{{current}} de {{total}}", "previous": "Paso anterior del recorrido", "next": "Siguiente paso del recorrido", - "finish": "Finalizar recorrido", "close": "Cerrar recorrido", "nextAction": "Siguiente", "doneAction": "Listo", - "animateProject": "Girar el cubo del proyecto", "chatPreview": { "prompt": "¿Puedes ayudarme a escribir un haiku sobre aves?", "responseLine1": "Las aves cosen el alba con su canto", @@ -66,30 +45,31 @@ "responseLine3": "La mañana aprende a volar", "typing": "Berd está escribiendo" }, - "projectPreview": { - "name": "Tu proyecto", - "description": "Un espacio para chats, archivos, contexto y trabajo continuo." - }, "steps": { "1": { - "title": "Tu lienzo, tu espacio", - "body": "Mantén a las personas, los proyectos y el trabajo que te importan juntos en un lienzo que es tuyo.", + "title": "Tu lienzo, tu hogar", + "body": "Fija lo que importa y encuéntralo rápido. Inicio es donde guardas lo que no quieres tener que buscar: fija un chat, un agente o un widget.", "visual": "Una vista previa del lienzo de inicio de Berd" }, "2": { - "title": "Habla con Berd sobre cualquier tema", - "body": "Haz preguntas, explora ideas y trabaja en una tarea con un agente que tenga el contexto adecuado.", - "visual": "Una conversación breve con Berd" + "title": "Todos tus proveedores de IA en un solo lugar", + "body": "Uses el proveedor que uses, puedes chatear con él aquí. Conecta más de uno y cambia entre ellos cuando quieras, incluso a mitad de un chat.", + "visual": "Una colección de iconos de proveedores de IA" }, "3": { - "title": "Conecta todos tus proveedores", - "body": "Reúne los modelos y proveedores que ya usas en un solo lugar y elige el adecuado para cada tarea.", - "visual": "Una colección de iconos de proveedores de IA" + "title": "Pregúntale cualquier cosa a Berd", + "body": "Haz preguntas, explora ideas o resuelve una tarea. Berd conserva el contexto para que puedas seguir avanzando.", + "visual": "Una conversación breve con Berd" }, "4": { - "title": "Da vida a tus proyectos", - "body": "Agrupa chats, archivos y directorios de trabajo en torno a iniciativas reales para que Berd te ayude a avanzar.", - "visual": "Un proyecto que cobra vida" + "title": "Los agentes se han unido al chat", + "body": "Un agente es un especialista con quien hablas directamente. @Menciónalo en un chat y trabajará contigo como un compañero, no como una herramienta.", + "visual": "Una colección de agentes de Berd" + }, + "5": { + "title": "Enséñale a Berd un truco nuevo con habilidades", + "body": "Las habilidades dan a Berd y a tus agentes instrucciones para una tarea, herramienta o flujo de trabajo específico, para que no tengas que explicarlo cada vez.", + "visual": "Una colección de habilidades reutilizables de Berd" } } }