diff --git a/web/ee/docker/Dockerfile.dev b/web/ee/docker/Dockerfile.dev index 719331462b..6a151cdff0 100644 --- a/web/ee/docker/Dockerfile.dev +++ b/web/ee/docker/Dockerfile.dev @@ -18,7 +18,7 @@ COPY ee/package.json ./ee/yarn.lock* ./ee/package-lock.json* ./ee/pnpm-lock.yaml COPY oss/package.json ./oss/yarn.lock* ./oss/package-lock.json* ./oss/pnpm-lock.yaml* ./oss/.npmrc* ./oss/ COPY ./pnpm-workspace.yaml ./turbo.json ./ -COPY ./entrypoint.sh /app/entrypoint.sh +COPY ./entrypoint.sh /app/entrypoint.sh RUN pnpm i diff --git a/web/oss/package.json b/web/oss/package.json index 32f32c8596..2bf130c858 100644 --- a/web/oss/package.json +++ b/web/oss/package.json @@ -95,6 +95,8 @@ "lexical": "^0.38.2", "lodash": "^4.17.21", "lucide-react": "^0.475.0", + "motion": "^12.23.24", + "@agentaai/nextstepjs": "^2.1.3-agenta.1", "next": "15.5.9", "papaparse": "^5.5.3", "postcss": "^8.5.6", @@ -137,4 +139,4 @@ "node-mocks-http": "^1.17.2", "tailwind-scrollbar": "^3" } -} +} \ No newline at end of file diff --git a/web/oss/src/components/DeploymentsDashboard/components/Drawer/index.tsx b/web/oss/src/components/DeploymentsDashboard/components/Drawer/index.tsx index 93f8eab0c7..4635c83e41 100644 --- a/web/oss/src/components/DeploymentsDashboard/components/Drawer/index.tsx +++ b/web/oss/src/components/DeploymentsDashboard/components/Drawer/index.tsx @@ -3,10 +3,11 @@ import {ComponentProps, ReactNode, useState} from "react" import {CloseOutlined, FullscreenExitOutlined, FullscreenOutlined} from "@ant-design/icons" import {Button, Divider, Drawer} from "antd" import clsx from "clsx" -import {useAtomValue} from "jotai" +import {useAtomValue, useSetAtom} from "jotai" import {createUseStyles} from "react-jss" import {envRevisionsAtom} from "@/oss/components/DeploymentsDashboard/atoms" +import {openSelectDeployVariantModalAtom} from "@/oss/components/DeploymentsDashboard/modals/store/deploymentModalsStore" import EnhancedDrawer from "@/oss/components/EnhancedUIs/Drawer" import {JSSTheme} from "@/oss/lib/Types" import {revisionListAtom} from "@/oss/state/variant/selectors/variant" @@ -98,6 +99,8 @@ const DeploymentsDrawerContent = ({ }: DeploymentsDrawerProps) => { const variants = useAtomValue(revisionListAtom) || [] const envRevisions = useAtomValue(envRevisionsAtom) + const openSelectDeployVariantModal = useSetAtom(openSelectDeployVariantModalAtom) + return (
@@ -118,7 +121,9 @@ const DeploymentsDrawerContent = ({ > {envRevisions ? ( close()} + handleOpenSelectDeployVariantModal={() => + openSelectDeployVariantModal({variants, envRevisions}) + } variants={variants} revisionId={drawerVariantId} selectedEnvironment={envRevisions} diff --git a/web/oss/src/components/DeploymentsDashboard/index.tsx b/web/oss/src/components/DeploymentsDashboard/index.tsx index f4efa3b1b7..4d96f1f6d3 100644 --- a/web/oss/src/components/DeploymentsDashboard/index.tsx +++ b/web/oss/src/components/DeploymentsDashboard/index.tsx @@ -98,6 +98,7 @@ const DeploymentsDashboard: FC = ({ diff --git a/web/oss/src/components/Layout/Layout.tsx b/web/oss/src/components/Layout/Layout.tsx index af4beca357..561cbb7f30 100644 --- a/web/oss/src/components/Layout/Layout.tsx +++ b/web/oss/src/components/Layout/Layout.tsx @@ -22,11 +22,15 @@ import CustomWorkflowBanner from "../CustomWorkflowBanner" import ProtectedRoute from "../ProtectedRoute/ProtectedRoute" import BreadcrumbContainer from "./assets/Breadcrumbs" -import {useStyles} from "./assets/styles" +import {StyleProps, useStyles} from "./assets/styles" import ErrorFallback from "./ErrorFallback" import {SidebarIsland} from "./SidebarIsland" import {getDeviceTheme, useAppTheme} from "./ThemeContextProvider" +const OnboardingWidget = dynamic(() => import("../Onboarding/components/OnboardingWidget"), { + ssr: false, + loading: () => null, +}) const FooterIsland = dynamic(() => import("./FooterIsland").then((m) => m.FooterIsland), { ssr: false, loading: () => null, @@ -305,6 +309,7 @@ const App: React.FC = ({children}) => { {children} {contextHolder} + )} diff --git a/web/oss/src/components/Onboarding/components/CustomNextStepProvider/index.tsx b/web/oss/src/components/Onboarding/components/CustomNextStepProvider/index.tsx new file mode 100644 index 0000000000..449a7b3665 --- /dev/null +++ b/web/oss/src/components/Onboarding/components/CustomNextStepProvider/index.tsx @@ -0,0 +1,115 @@ +import {useEffect, useRef} from "react" + +import {useAtomValue, useSetAtom} from "jotai" +import {NextStep, useNextStep} from "@agentaai/nextstepjs" + +import { + isNewUserAtom, + onboardingStepsAtom, + triggerOnboardingAtom, + userOnboardingStatusAtom, +} from "@/oss/state/onboarding" +import {urlLocationAtom} from "@/oss/state/url" + +import OnboardingCard from "../../index" +import OnboardingAutoAdvance from "../OnboardingAutoAdvance" + +const CustomNextStepProvider = ({children}: {children: React.ReactNode}) => { + const onboardingSteps = useAtomValue(onboardingStepsAtom) + const isNewUser = useAtomValue(isNewUserAtom) + const userLocation = useAtomValue(urlLocationAtom) + const userOnboardingJourneyStatus = useAtomValue(userOnboardingStatusAtom) + const manualTrigger = useAtomValue(triggerOnboardingAtom) + const setTriggerOnboarding = useSetAtom(triggerOnboardingAtom) + const {startNextStep} = useNextStep() + const autoStartSignatureRef = useRef(null) + + const previousSectionRef = useRef(userLocation.resolvedSection ?? userLocation.section) + useEffect(() => { + const resolvedSection = userLocation.resolvedSection ?? userLocation.section + if (previousSectionRef.current !== resolvedSection) { + previousSectionRef.current = resolvedSection + if (!manualTrigger) { + setTriggerOnboarding(null) + } + } + }, [userLocation.resolvedSection, userLocation.section, manualTrigger, setTriggerOnboarding]) + + useEffect(() => { + if (!isNewUser) { + autoStartSignatureRef.current = null + return + } + + const normalizedSection = userLocation.resolvedSection + if (!normalizedSection) { + autoStartSignatureRef.current = null + return + } + + if (!onboardingSteps?.length) { + autoStartSignatureRef.current = null + return + } + + const currentTour = onboardingSteps[0] + if (!currentTour?.tour) return + + const tourSection = + (currentTour.steps?.find((step) => step?.onboardingSection) + ?.onboardingSection as keyof typeof userOnboardingJourneyStatus) ?? + normalizedSection + + const signature = `${tourSection}:${currentTour.tour}:${currentTour.steps?.length ?? 0}` + if (autoStartSignatureRef.current === signature) return + + autoStartSignatureRef.current = signature + + startNextStep(currentTour.tour) + }, [ + isNewUser, + userLocation.resolvedSection, + userOnboardingJourneyStatus, + onboardingSteps, + startNextStep, + ]) + + const lastManualTriggerRef = useRef(null) + useEffect(() => { + if (!manualTrigger) { + lastManualTriggerRef.current = null + return + } + if (!onboardingSteps?.length) return + if (lastManualTriggerRef.current === manualTrigger) return + + lastManualTriggerRef.current = manualTrigger + const targetTour = + manualTrigger.tourId !== undefined + ? (onboardingSteps.find((tour) => tour.tour === manualTrigger.tourId) ?? + onboardingSteps[0]) + : onboardingSteps[0] + const tourId = targetTour?.tour + if (!tourId) return + + startNextStep(tourId) + }, [manualTrigger, onboardingSteps, startNextStep]) + + const handleComplete = () => { + setTriggerOnboarding(null) + } + + return ( + + + {children} + + ) +} + +export default CustomNextStepProvider diff --git a/web/oss/src/components/Onboarding/components/NextViewport/index.tsx b/web/oss/src/components/Onboarding/components/NextViewport/index.tsx new file mode 100644 index 0000000000..00f9c1e4c3 --- /dev/null +++ b/web/oss/src/components/Onboarding/components/NextViewport/index.tsx @@ -0,0 +1,67 @@ +import {forwardRef, useEffect, useRef, type MutableRefObject, type ReactNode} from "react" + +import clsx from "clsx" + +interface NextViewportProps { + id: string + className?: string + children: ReactNode +} + +const NextViewport = forwardRef( + ({id, className, children}, forwardedRef) => { + const containerRef = useRef(null) + + useEffect(() => { + const node = containerRef.current + if (typeof window === "undefined" || !node) return + + let animationFrame: number | null = null + const notifyViewportChange = () => { + window.dispatchEvent(new CustomEvent("nextstep:viewport-scroll", {detail: {id}})) + window.dispatchEvent(new Event("resize")) + } + + const scheduleNotification = () => { + if (animationFrame) cancelAnimationFrame(animationFrame) + animationFrame = window.requestAnimationFrame(() => { + notifyViewportChange() + }) + } + + const resizeObserver = + typeof ResizeObserver !== "undefined" + ? new ResizeObserver(() => scheduleNotification()) + : null + + resizeObserver?.observe(node) + node.addEventListener("scroll", scheduleNotification, {passive: true}) + scheduleNotification() + + return () => { + node.removeEventListener("scroll", scheduleNotification) + resizeObserver?.disconnect() + if (animationFrame) cancelAnimationFrame(animationFrame) + } + }, [id]) + + const setRef = (node: HTMLDivElement | null) => { + containerRef.current = node + if (typeof forwardedRef === "function") { + forwardedRef(node) + } else if (forwardedRef) { + ;(forwardedRef as MutableRefObject).current = node + } + } + + return ( +
+ {children} +
+ ) + }, +) + +NextViewport.displayName = "NextViewport" + +export default NextViewport diff --git a/web/oss/src/components/Onboarding/components/OnboardingAutoAdvance/index.tsx b/web/oss/src/components/Onboarding/components/OnboardingAutoAdvance/index.tsx new file mode 100644 index 0000000000..7c12e0f303 --- /dev/null +++ b/web/oss/src/components/Onboarding/components/OnboardingAutoAdvance/index.tsx @@ -0,0 +1,97 @@ +import {useEffect, useMemo} from "react" + +import {useAtomValue, useSetAtom} from "jotai" +import {useNextStep} from "@agentaai/nextstepjs" + +import { + onboardingStepsAtom, + triggerOnboardingAtom, + updateUserOnboardingStatusAtom, +} from "@/oss/state/onboarding" +import type {OnboardingStep, UserOnboardingStatus} from "@/oss/state/onboarding/types" +import {urlLocationAtom} from "@/oss/state/url" + +const OnboardingAutoAdvance = () => { + const onboardingTours = useAtomValue(onboardingStepsAtom) + const updateUserOnboardingStatus = useSetAtom(updateUserOnboardingStatusAtom) + const setTriggerOnboarding = useSetAtom(triggerOnboardingAtom) + const userLocation = useAtomValue(urlLocationAtom) + const {currentTour, currentStep, isNextStepVisible, setCurrentStep, closeNextStep} = + useNextStep() + + const {activeStep, totalSteps} = useMemo(() => { + if (!currentTour) { + return {activeStep: null, totalSteps: 0} + } + + const matchingTour = onboardingTours.find((tour) => tour.tour === currentTour) + if (!matchingTour) { + return {activeStep: null, totalSteps: 0} + } + + const step = matchingTour.steps[currentStep] as OnboardingStep | undefined + return {activeStep: step ?? null, totalSteps: matchingTour.steps.length} + }, [currentTour, currentStep, onboardingTours]) + + const resolvedSectionFromLocation = useMemo( + () => userLocation.resolvedSection ?? userLocation.section, + [userLocation.resolvedSection, userLocation.section], + ) + + useEffect(() => { + if (!activeStep?.advanceOnClick || !activeStep.selector || !isNextStepVisible) { + return + } + + const handleClick = async (event: MouseEvent) => { + if (!activeStep.selector) return + + const target = event.target + if (!(target instanceof Element)) return + + const matchedTarget = target.closest(activeStep.selector) + if (!matchedTarget) { + return + } + + try { + await activeStep.onNext?.() + } catch (error) { + console.error("Failed to run onboarding advance handler", error) + } + + if (currentStep >= totalSteps - 1) { + const resolvedSection = + (activeStep?.onboardingSection as keyof UserOnboardingStatus | undefined) ?? + resolvedSectionFromLocation + if (resolvedSection) { + updateUserOnboardingStatus({section: resolvedSection, status: "done"}) + } + setTriggerOnboarding(null) + closeNextStep() + return + } + + setCurrentStep(currentStep + 1) + } + + document.addEventListener("click", handleClick, true) + return () => { + document.removeEventListener("click", handleClick, true) + } + }, [ + activeStep, + currentStep, + totalSteps, + isNextStepVisible, + closeNextStep, + setCurrentStep, + resolvedSectionFromLocation, + setTriggerOnboarding, + updateUserOnboardingStatus, + ]) + + return null +} + +export default OnboardingAutoAdvance diff --git a/web/oss/src/components/Onboarding/components/OnboardingTriggerButton/index.tsx b/web/oss/src/components/Onboarding/components/OnboardingTriggerButton/index.tsx new file mode 100644 index 0000000000..a8bb50bec7 --- /dev/null +++ b/web/oss/src/components/Onboarding/components/OnboardingTriggerButton/index.tsx @@ -0,0 +1,61 @@ +import {memo, useCallback, useMemo, MouseEvent} from "react" + +import {QuestionCircleOutlined} from "@ant-design/icons" +import {Button, Tooltip} from "antd" +import type {ButtonProps, TooltipProps} from "antd" +import {useAtomValue, useSetAtom} from "jotai" + +import {triggerOnboardingAtom} from "@/oss/state/onboarding" +import {urlLocationAtom} from "@/oss/state/url" + +import {OnboardingTriggerButtonProps} from "./types" + +const OnboardingTriggerButton = ({ + triggerPayload, + tooltipTitle, + tooltipProps, + buttonProps, + children, +}: OnboardingTriggerButtonProps) => { + const setTriggerOnboarding = useSetAtom(triggerOnboardingAtom) + const userLocation = useAtomValue(urlLocationAtom) + const normalizedSection = userLocation.resolvedSection + + const handleClick: ButtonProps["onClick"] = useCallback( + (event: MouseEvent) => { + buttonProps?.onClick?.(event) + const payload = + triggerPayload ?? (normalizedSection ? {state: normalizedSection} : null) + if (!payload) return + setTriggerOnboarding(payload) + }, + [buttonProps, triggerPayload, normalizedSection, setTriggerOnboarding], + ) + + const effectiveTooltipTitle = + tooltipTitle ?? "Need a hand? Launch the guided walkthrough for this page." + const mergedTooltipProps: TooltipProps = useMemo( + () => ({ + mouseEnterDelay: 0.5, + ...tooltipProps, + title: tooltipProps?.title ?? effectiveTooltipTitle, + }), + [tooltipProps, effectiveTooltipTitle], + ) + + return ( + + + + ) +} + +export default memo(OnboardingTriggerButton) diff --git a/web/oss/src/components/Onboarding/components/OnboardingTriggerButton/types.ts b/web/oss/src/components/Onboarding/components/OnboardingTriggerButton/types.ts new file mode 100644 index 0000000000..76323af9dc --- /dev/null +++ b/web/oss/src/components/Onboarding/components/OnboardingTriggerButton/types.ts @@ -0,0 +1,20 @@ +import type {ReactNode} from "react" + +import type {ButtonProps, TooltipProps} from "antd" + +import type {UserOnboardingStatus} from "@/oss/state/onboarding/types" + +export interface TriggerPayload { + state: keyof UserOnboardingStatus + tourId?: string +} + +export interface OnboardingTriggerButtonProps { + triggerOnboarding?: (payload: TriggerPayload | null) => void + triggerPayload?: TriggerPayload + tooltipTitle?: ReactNode + tooltipProps?: TooltipProps + buttonProps?: ButtonProps + icon?: ReactNode + children?: ReactNode +} diff --git a/web/oss/src/components/Onboarding/components/OnboardingWidget/constants.ts b/web/oss/src/components/Onboarding/components/OnboardingWidget/constants.ts new file mode 100644 index 0000000000..f4f4d7643e --- /dev/null +++ b/web/oss/src/components/Onboarding/components/OnboardingWidget/constants.ts @@ -0,0 +1,8 @@ +export const BOUNDARY_PADDING = 24 +export const TOGGLE_DRAG_THRESHOLD = 4 +export const CLOSING_ANIMATION_MS = 250 + +export const CHECKLIST_PREREQUISITES = { + needsApp: "needsApp", + needsProject: "needsProject", +} as const diff --git a/web/oss/src/components/Onboarding/components/OnboardingWidget/index.tsx b/web/oss/src/components/Onboarding/components/OnboardingWidget/index.tsx new file mode 100644 index 0000000000..157ddbf90f --- /dev/null +++ b/web/oss/src/components/Onboarding/components/OnboardingWidget/index.tsx @@ -0,0 +1,456 @@ +import {useCallback, useEffect, useMemo, useRef, useState} from "react" + +import {X} from "@phosphor-icons/react" +import {Button, Checkbox, Progress, Tooltip, Typography} from "antd" +import clsx from "clsx" +import {getDefaultStore, useAtom, useAtomValue, useSetAtom} from "jotai" +import {useRouter} from "next/router" + +import useURL from "@/oss/hooks/useURL" +import { + isNewUserAtom, + triggerOnboardingAtom, + userOnboardingStatusAtom, +} from "@/oss/state/onboarding" +import { + currentRunningWidgetOnboardingAtom, + onboardingWidgetClosedAtom, + onboardingWidgetCompletionAtom, + onboardingWidgetPositionAtom, + onboardingWidgetSkippedAtom, + type OnboardingWidgetPosition, +} from "@/oss/state/onboarding/atoms/widgetAtom" +import {userAtom} from "@/oss/state/profile" +import {sessionExistsAtom} from "@/oss/state/session" + +import { + trackOnboardingAllTasksCompleted, + trackOnboardingGuideClosed, + trackOnboardingTaskCompleted, +} from "../../utils/trackOnboarding" + +import {BOUNDARY_PADDING} from "./constants" +import {type ChecklistItem} from "./types" +import {buildChecklistSections, clamp} from "./utils" + +interface ElementSize { + width: number + height: number +} + +const DEFAULT_WIDGET_SIZE: ElementSize = {width: 280, height: 360} + +const normalizeRoutePath = (value: string) => { + if (!value) return "" + const withoutOrigin = value.replace(/^https?:\/\/[^/]+/i, "") + const [pathWithQuery] = withoutOrigin.split("#") + const [path, query] = pathWithQuery.split("?") + if (!path || path === "/") { + return query ? `/?${query}` : "/" + } + const normalizedPath = path.replace(/\/+$/, "") + return query ? `${normalizedPath}?${query}` : normalizedPath +} + +export const setCompleteWidgetTaskMap = (key: string) => { + const store = getDefaultStore() + store.set(onboardingWidgetCompletionAtom, (prev) => ({ + ...prev, + [key]: true, + })) +} + +const OnboardingWidget = () => { + const router = useRouter() + const {projectURL, appURL, recentlyVisitedAppURL} = useURL() + const [completedMap, setCompletedMap] = useAtom(onboardingWidgetCompletionAtom) + const [skippedMap] = useAtom(onboardingWidgetSkippedAtom) + const [currentRunningWidgetOnboarding, setCurrentRunningWidgetOnboarding] = useAtom( + currentRunningWidgetOnboardingAtom, + ) + const [storedPosition, setStoredPosition] = useAtom(onboardingWidgetPositionAtom) + const [isClosed, setIsClosed] = useAtom(onboardingWidgetClosedAtom) + const setTriggerOnboarding = useSetAtom(triggerOnboardingAtom) + const userOnboardingStatus = useAtomValue(userOnboardingStatusAtom) + const isNewUser = useAtomValue(isNewUserAtom) + const sessionExists = useAtomValue(sessionExistsAtom) + const user = useAtomValue(userAtom) + const canInitializeVisibility = sessionExists && Boolean(user) + + const widgetRef = useRef(null) + const dragStateRef = useRef<{ + offsetX: number + offsetY: number + width: number + height: number + } | null>(null) + const appliedVisibilityStateRef = useRef(null) + const autoOpenedRef = useRef(false) + const autoClosedRef = useRef(false) + const [isDragging, setIsDragging] = useState(false) + const [widgetSize, setWidgetSize] = useState(null) + + const shouldRenderWidget = !isClosed + + useEffect(() => { + if (!canInitializeVisibility) return + if (appliedVisibilityStateRef.current) return + + if (isNewUser && !isClosed) { + setIsClosed(false) + autoOpenedRef.current = true + autoClosedRef.current = false + } + + appliedVisibilityStateRef.current = true + }, [canInitializeVisibility, isClosed, isNewUser, setIsClosed]) + + // marking widget onboarding as completed + useEffect(() => { + if (!currentRunningWidgetOnboarding) return + const {section, completionKey, initialStatus} = currentRunningWidgetOnboarding + const currentStatus = userOnboardingStatus[section] + if (currentStatus === initialStatus) return + if (currentStatus === "idle") return + if (completedMap[completionKey]) return + + setCompletedMap((prev) => { + const next = {...prev, [completionKey]: true} + trackOnboardingTaskCompleted({ + ...buildBasePayload(), + completed_tasks: Object.keys(next).length, + task_id: completionKey, + }) + return next + }) + + setCurrentRunningWidgetOnboarding(null) + }, [currentRunningWidgetOnboarding, userOnboardingStatus, completedMap, skippedMap]) + + const sections = useMemo( + () => + buildChecklistSections({ + projectURL, + appURL, + recentlyVisitedAppURL, + }), + [projectURL, appURL, recentlyVisitedAppURL], + ) + + const getCompletionKey = useCallback( + (item: ChecklistItem) => item.tour?.tourId ?? item.tour?.section ?? item.id, + [], + ) + + const allItems = useMemo( + () => sections.reduce((acc, section) => acc.concat(section.items), []), + [sections], + ) + const totalTasks = allItems.length + const completedCount = useMemo(() => { + return allItems.reduce( + (count, item) => (completedMap[getCompletionKey(item)] ? count + 1 : count), + 0, + ) + }, [allItems, completedMap, getCompletionKey]) + + const skippedCount = useMemo(() => { + return allItems.reduce( + (count, item) => (skippedMap[getCompletionKey(item)] ? count + 1 : count), + 0, + ) + }, [allItems, skippedMap, getCompletionKey]) + + const buildBasePayload = useCallback( + () => ({ + total_tasks: totalTasks, + completed_tasks: completedCount, + skipped_tasks: skippedCount, + }), + [totalTasks, completedCount, skippedCount], + ) + const progressPercent = totalTasks ? Math.round((completedCount / totalTasks) * 100) : 0 + const totalDone = completedCount + skippedCount + + useEffect(() => { + if (!canInitializeVisibility) return + if (!isNewUser) return + if (!autoOpenedRef.current) return + if (autoClosedRef.current) return + if (!totalTasks) return + if (totalDone < totalTasks) return + + if (!autoClosedRef.current) { + trackOnboardingAllTasksCompleted(buildBasePayload()) + trackOnboardingGuideClosed({ + ...buildBasePayload(), + close_reason: "auto_all_done", + }) + } + + setIsClosed(true) + autoClosedRef.current = true + }, [canInitializeVisibility, isNewUser, totalDone, totalTasks]) + + const onClickGuideItem = useCallback( + async (item: ChecklistItem) => { + if (item.disabled) return + + const navigateIfNeeded = async () => { + if (!item.href) return true + const currentPath = normalizeRoutePath(router.asPath) + const targetPath = normalizeRoutePath(item.href) + if (currentPath === targetPath) { + return true + } + try { + await router.push(item.href) + return true + } catch (error) { + console.error("Failed to navigate to onboarding target", error) + return false + } + } + + const navigationCompleted = await navigateIfNeeded() + if (!navigationCompleted) return + + const completionKey = getCompletionKey(item) + if (!item.tour) { + setCompletedMap((prev) => { + const next = {...prev, [completionKey]: true} + trackOnboardingTaskCompleted({ + ...buildBasePayload(), + completed_tasks: Object.keys(next).length, + task_id: completionKey, + }) + return next + }) + return + } + + setTriggerOnboarding({state: item.tour.section, tourId: item.tour?.tourId}) + setCurrentRunningWidgetOnboarding({ + section: item.tour.section, + completionKey, + initialStatus: userOnboardingStatus[item.tour.section], + }) + }, + [ + router, + setCompletedMap, + setTriggerOnboarding, + setCurrentRunningWidgetOnboarding, + getCompletionKey, + userOnboardingStatus, + buildBasePayload, + trackOnboardingTaskCompleted, + ], + ) + + const onClose = useCallback(() => { + setIsClosed(true) + trackOnboardingGuideClosed({ + ...buildBasePayload(), + close_reason: "manual", + }) + setTriggerOnboarding({state: "apps", tourId: "reopen-onboarding-guide"}) + }, [setIsClosed, buildBasePayload, setTriggerOnboarding]) + + const startDragging = useCallback((event: React.PointerEvent) => { + if (event.button !== 0) return + if (!widgetRef.current) return + event.preventDefault() + event.stopPropagation() + const rect = widgetRef.current.getBoundingClientRect() + dragStateRef.current = { + offsetX: event.clientX - rect.left, + offsetY: event.clientY - rect.top, + width: rect.width, + height: rect.height, + } + setIsDragging(true) + }, []) + + const stopDragging = useCallback(() => { + if (!isDragging) return + setIsDragging(false) + dragStateRef.current = null + }, [isDragging, setStoredPosition]) + + const getWidgetSize = useCallback(() => widgetSize ?? DEFAULT_WIDGET_SIZE, [widgetSize]) + + const clampToViewport = useCallback((position: OnboardingWidgetPosition, size: ElementSize) => { + const maxX = Math.max(BOUNDARY_PADDING, window.innerWidth - size.width - BOUNDARY_PADDING) + const maxY = Math.max(BOUNDARY_PADDING, window.innerHeight - size.height - BOUNDARY_PADDING) + return { + x: clamp(position.x, BOUNDARY_PADDING, maxX), + y: clamp(position.y, BOUNDARY_PADDING, maxY), + } + }, []) + + useEffect(() => { + if (!widgetRef.current) return + const element = widgetRef.current + const updateSize = () => { + const rect = element.getBoundingClientRect() + setWidgetSize({width: rect.width, height: rect.height}) + } + updateSize() + const observer = new ResizeObserver(updateSize) + observer.observe(element) + return () => observer.disconnect() + }, [shouldRenderWidget]) + + useEffect(() => { + const handleResize = () => { + setStoredPosition((prev) => { + if (!prev) return prev + const clamped = clampToViewport(prev, getWidgetSize()) + return clamped.x === prev.x && clamped.y === prev.y ? prev : clamped + }) + } + + handleResize() + window.addEventListener("resize", handleResize) + return () => window.removeEventListener("resize", handleResize) + }, [clampToViewport, getWidgetSize, setStoredPosition]) + + useEffect(() => { + if (!isDragging) return + + const handlePointerMove = (event: PointerEvent) => { + if (!dragStateRef.current) return + const {offsetX, offsetY, width, height} = dragStateRef.current + const maxX = Math.max(BOUNDARY_PADDING, window.innerWidth - width - BOUNDARY_PADDING) + const maxY = Math.max(BOUNDARY_PADDING, window.innerHeight - height - BOUNDARY_PADDING) + const nextX = clamp(event.clientX - offsetX, BOUNDARY_PADDING, maxX) + const nextY = clamp(event.clientY - offsetY, BOUNDARY_PADDING, maxY) + setStoredPosition({x: nextX, y: nextY}) + } + + const handlePointerUp = () => { + stopDragging() + } + + window.addEventListener("pointermove", handlePointerMove) + window.addEventListener("pointerup", handlePointerUp) + + return () => { + window.removeEventListener("pointermove", handlePointerMove) + window.removeEventListener("pointerup", handlePointerUp) + } + }, [isDragging, stopDragging, setStoredPosition]) + + useEffect(() => { + if (!isDragging) return + const previousUserSelect = document.body.style.userSelect + document.body.style.userSelect = "none" + return () => { + document.body.style.userSelect = previousUserSelect + } + }, [isDragging]) + + const containerStyle: React.CSSProperties = { + position: "fixed", + zIndex: 900, + ...(storedPosition + ? {top: storedPosition.y, left: storedPosition.x} + : {bottom: BOUNDARY_PADDING, right: BOUNDARY_PADDING}), + } + + return ( + <> + {shouldRenderWidget && ( +
+
+
+
+
+ + Onboarding Guide + + + {completedCount} of {totalTasks} tasks completed + +
+ +
+
+ +
+ +
+ +
+ {sections.map((section) => ( +
+ + {section.title} + +
+ {section.items.map((item) => { + const isCompleted = Boolean( + completedMap[getCompletionKey(item)], + ) + return ( +
+ !item.disabled && onClickGuideItem(item) + } + className={clsx([ + "flex items-center gap-2 rounded-lg px-4 py-3", + "border border-solid border-colorBorderSecondary", + + { + "cursor-not-allowed opacity-60": + item.disabled, + "bg-colorBgContainerDisabled": + isCompleted, + "cursor-pointer hover:bg-gray-50 transition-all": + !isCompleted && !item.disabled, + }, + ])} + > + + + + {item.title} + +
+ ) + })} +
+
+ ))} +
+
+
+ )} + + ) +} + +export default OnboardingWidget diff --git a/web/oss/src/components/Onboarding/components/OnboardingWidget/types.ts b/web/oss/src/components/Onboarding/components/OnboardingWidget/types.ts new file mode 100644 index 0000000000..dcaf6631b7 --- /dev/null +++ b/web/oss/src/components/Onboarding/components/OnboardingWidget/types.ts @@ -0,0 +1,35 @@ +import type {UserOnboardingStatus} from "@/oss/state/onboarding" + +import {CHECKLIST_PREREQUISITES} from "./constants" + +export type ChecklistPrerequisite = + (typeof CHECKLIST_PREREQUISITES)[keyof typeof CHECKLIST_PREREQUISITES] + +export interface ChecklistItemTour { + section: keyof UserOnboardingStatus + tourId?: string +} + +export interface ChecklistItem { + id: string + title: string + description: string + href?: string + disabled?: boolean + tip?: string + cta?: string + tour?: ChecklistItemTour + prerequisites?: ChecklistPrerequisite[] +} + +export interface ChecklistSection { + id: string + title: string + items: ChecklistItem[] +} + +export interface ChecklistContext { + projectURL: string + appURL: string + recentlyVisitedAppURL: string +} diff --git a/web/oss/src/components/Onboarding/components/OnboardingWidget/utils.tsx b/web/oss/src/components/Onboarding/components/OnboardingWidget/utils.tsx new file mode 100644 index 0000000000..df8e62aaec --- /dev/null +++ b/web/oss/src/components/Onboarding/components/OnboardingWidget/utils.tsx @@ -0,0 +1,125 @@ +import {CHECKLIST_PREREQUISITES} from "./constants" +import {ChecklistContext, ChecklistSection} from "./types" + +export const buildChecklistSections = ({ + projectURL, + appURL, + recentlyVisitedAppURL, +}: ChecklistContext): ChecklistSection[] => { + const appBase = appURL || recentlyVisitedAppURL + const hasAppTarget = Boolean(appBase) + const evaluationBase = projectURL ? `${projectURL}/evaluations` : "" + + const getEvaluationLink = (tab: string) => + evaluationBase ? `${evaluationBase}?selectedEvaluation=${tab}` : undefined + + return [ + { + id: "guides", + title: "Guides", + items: [ + { + id: "create-app", + title: "Create your first app", + description: + "Start by creating your first app to unlock the rest of the guide.", + href: `${projectURL}/apps`, + disabled: !projectURL, + tip: projectURL ? undefined : "Open a project to create your first app.", + cta: "Open App Management", + tour: {section: "apps", tourId: "create-first-app"}, + prerequisites: [CHECKLIST_PREREQUISITES.needsProject], + }, + { + id: "create-first-prompt", + title: "Create your first prompt", + description: "Open the playground and design your first prompt or template.", + href: hasAppTarget ? `${appBase}/playground` : undefined, + disabled: !hasAppTarget, + tip: hasAppTarget ? undefined : "Select an app to enter the playground.", + cta: hasAppTarget ? "Go to Playground" : "Open App Management", + tour: {section: "playground", tourId: "playground-quickstart"}, + prerequisites: [CHECKLIST_PREREQUISITES.needsApp], + }, + // { + // id: "first-evaluation", + // title: "Run your first evaluation", + // description: "Compare prompts with a quick automatic evaluation.", + // href: getEvaluationLink("auto_evaluation"), + // disabled: !evaluationBase, + // tip: evaluationBase + // ? undefined + // : "You need a project before you can run evaluations.", + // cta: "Launch evaluation", + // tour: {section: "autoEvaluation", tourId: "one-click-auto-evaluation"}, + // prerequisites: [CHECKLIST_PREREQUISITES.needsProject], + // }, + { + id: "online-evaluation", + title: "Set up online evaluation", + description: "Send live traffic to variants and capture production results.", + href: getEvaluationLink("online_evaluation"), + disabled: !evaluationBase, + tip: evaluationBase + ? undefined + : "Project-level access is required for online evaluations.", + cta: "Configure online eval", + tour: {section: "onlineEvaluation", tourId: "one-click-online-evaluation"}, + prerequisites: [CHECKLIST_PREREQUISITES.needsProject], + }, + ], + }, + { + id: "integrations", + title: "Technical Integrations", + items: [ + { + id: "prompt-management", + title: "Set up prompt management", + description: "Organize prompt variants in the registry for easy deployment.", + href: hasAppTarget ? `${appBase}/deployments` : undefined, + disabled: !hasAppTarget, + tip: hasAppTarget ? undefined : "Select an app to reach the registry.", + cta: "Open registry", + tour: {section: "deployment", tourId: "prompt-setup"}, + prerequisites: [CHECKLIST_PREREQUISITES.needsApp], + }, + { + id: "set-up-tracing", + title: "Set up tracing", + description: "Inspect traces and annotate executions for better observability.", + href: projectURL ? `${projectURL}/apps` : undefined, + disabled: !projectURL, + tip: projectURL ? undefined : "Select a project to configure tracing.", + cta: "Open app management", + tour: {section: "apps", tourId: "trace-setup"}, + prerequisites: [CHECKLIST_PREREQUISITES.needsProject], + }, + ], + }, + { + id: "collaboration", + title: "Collaboration", + items: [ + { + id: "invite-team", + title: "Invite your team", + description: + "Share Agenta with collaborators directly from workspace settings.", + href: projectURL + ? `${projectURL}/settings?tab=workspace&inviteModal=open` + : undefined, + disabled: !projectURL, + tip: projectURL + ? undefined + : "Create or open a workspace project to manage teammates.", + cta: "Open workspace settings", + prerequisites: [CHECKLIST_PREREQUISITES.needsProject], + }, + ], + }, + ] +} + +export const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), max) diff --git a/web/oss/src/components/Onboarding/index.tsx b/web/oss/src/components/Onboarding/index.tsx new file mode 100644 index 0000000000..20b1ab30d2 --- /dev/null +++ b/web/oss/src/components/Onboarding/index.tsx @@ -0,0 +1,287 @@ +import type {CSSProperties} from "react" +import {cloneElement, isValidElement, useCallback, useEffect, useMemo, useRef} from "react" + +import {ArrowLeft, ArrowRight} from "@phosphor-icons/react" +import {Button, Card, Typography} from "antd" +import clsx from "clsx" +import {useAtomValue, useSetAtom} from "jotai" +import type {CardComponentProps} from "@agentaai/nextstepjs" + +import type {OnboardingControlLabels, UserOnboardingStatus} from "@/oss/state/onboarding" +import { + currentOnboardingStepAtom, + isNewUserAtom, + triggerOnboardingAtom, + updateUserOnboardingStatusAtom, +} from "@/oss/state/onboarding" +import {urlLocationAtom} from "@/oss/state/url" + +import {NormalizedStepContent} from "./types" + +const {Text} = Typography + +type StepWithEffects = CardComponentProps["step"] & { + onEnter?: () => void + onExit?: () => void + onCleanup?: () => void + onNext?: () => void | Promise + onboardingSection?: keyof UserOnboardingStatus + controlLabels?: OnboardingControlLabels +} + +const normalizeStep = (step: CardComponentProps["step"]): NormalizedStepContent => { + const extendedStep = step as StepWithEffects | undefined + return { + icon: step?.icon ?? null, + title: step?.title, + content: step?.content, + showControls: step?.showControls ?? true, + showSkip: step?.showSkip ?? true, + controlLabels: extendedStep?.controlLabels, + } +} + +const OnboardingCard = ({ + step, + currentStep, + totalSteps, + prevStep, + nextStep, + skipTour, + arrow, +}: CardComponentProps) => { + const setCurrentStep = useSetAtom(currentOnboardingStepAtom) + const updateOnboardingStatus = useSetAtom(updateUserOnboardingStatusAtom) + const isNewUser = useAtomValue(isNewUserAtom) + const userOnboardingSection = useAtomValue(urlLocationAtom).resolvedSection + const setTriggerOnboarding = useSetAtom(triggerOnboardingAtom) + + const cleanupHandlersRef = useRef void>>(new Set()) + + const runCleanupHandlers = useCallback(() => { + if (!cleanupHandlersRef.current.size) return + + cleanupHandlersRef.current.forEach((cleanup) => { + try { + cleanup() + } catch (error) { + console.error("Failed to run onboarding cleanup handler", error) + } + }) + cleanupHandlersRef.current.clear() + }, []) + + useEffect(() => { + if (!step?.selector && currentStep == null) { + skipTour?.() + } + }, [step, skipTour, currentStep]) + + useEffect(() => { + if (!step) return + + const extendedStep = step as StepWithEffects | undefined + setCurrentStep({...step, currentStep, totalSteps}) + extendedStep?.onEnter?.() + + if (extendedStep?.onCleanup) { + cleanupHandlersRef.current.add(extendedStep.onCleanup) + } + + return () => { + extendedStep?.onExit?.() + } + }, [step, setCurrentStep, currentStep, totalSteps]) + + useEffect(() => { + return () => { + setCurrentStep(null) + runCleanupHandlers() + } + }, [setCurrentStep, runCleanupHandlers]) + + const onSkipStep = useCallback( + (status: string) => { + const extendedStep = step as StepWithEffects | undefined + runCleanupHandlers() + skipTour?.() + setTriggerOnboarding(null) + if (!status) return + + const resolvedSection = extendedStep?.onboardingSection ?? userOnboardingSection + if (!resolvedSection) return + + updateOnboardingStatus({section: resolvedSection, status}) + }, + [ + step, + skipTour, + isNewUser, + userOnboardingSection, + updateOnboardingStatus, + runCleanupHandlers, + setTriggerOnboarding, + ], + ) + + const handleAdvance = useCallback( + async (isFinalStep?: boolean) => { + const extendedStep = step as StepWithEffects | undefined + try { + if (extendedStep?.onNext) { + await extendedStep.onNext() + } + } catch (error) { + console.error("Failed to run onboarding advance handler", error) + } + if (isFinalStep) { + onSkipStep("done") + return + } + nextStep() + }, + [step, nextStep, onSkipStep], + ) + + const onPrevStep = useCallback(() => { + prevStep() + }, [prevStep]) + + const onNextStep = useCallback(() => { + handleAdvance() + }, [handleAdvance]) + + const normalized = useMemo(() => normalizeStep(step), [step]) + const percent = useMemo( + () => Math.round(((currentStep + 1) / totalSteps) * 100), + [currentStep, totalSteps], + ) + const controlLabels = normalized.controlLabels ?? ({} as OnboardingControlLabels) + const previousLabel = controlLabels.previous ?? "Previous" + const nextLabel = controlLabels.next ?? "Next" + const finishLabel = controlLabels.finish ?? "Finish" + + const adjustedArrow = useMemo(() => { + if (!isValidElement(arrow)) return null + const baseStyle = arrow.props?.style ?? {} + const offset = 12 + const nextStyle: CSSProperties = { + ...baseStyle, + color: "#ffffff", + backgroundColor: "white", + } + + if (typeof baseStyle.top === "string") { + nextStyle.top = `-${offset}px` + } + if (typeof baseStyle.bottom === "string") { + nextStyle.bottom = `-${offset}px` + } + if (typeof baseStyle.left === "string") { + nextStyle.left = `-${offset}px` + } + if (typeof baseStyle.right === "string") { + nextStyle.right = `-${offset}px` + } + + return cloneElement(arrow, { + style: nextStyle, + }) + }, [arrow]) + + const progressWidth = useMemo(() => `${percent}%`, [percent]) + + return ( +
+ +
+
+
+ + {normalized.title} + + + + {currentStep + 1} / {totalSteps} + +
+ + + {normalized.content} + +
+ {normalized.showControls ? ( +
+
+
+
+ +
+ + + {currentStep < totalSteps - 1 ? ( + + ) : ( + + )} +
+
+ ) : null} +
+ + {normalized.showSkip && skipTour ? ( + + ) : null} + + {adjustedArrow ? ( +
{adjustedArrow}
+ ) : null} + +
+ ) +} + +export default OnboardingCard diff --git a/web/oss/src/components/Onboarding/types.ts b/web/oss/src/components/Onboarding/types.ts new file mode 100644 index 0000000000..463d504be0 --- /dev/null +++ b/web/oss/src/components/Onboarding/types.ts @@ -0,0 +1,18 @@ +import {ReactNode} from "react" + +import {ModalProps} from "antd" + +import type {OnboardingControlLabels} from "@/oss/state/onboarding/types" + +export interface NormalizedStepContent { + icon: ReactNode + title: ReactNode + content: ReactNode + showControls: boolean + showSkip: boolean + controlLabels?: OnboardingControlLabels +} + +export interface WelcomeModalProps extends ModalProps { + open: boolean +} diff --git a/web/oss/src/components/Onboarding/utils/trackOnboarding.ts b/web/oss/src/components/Onboarding/utils/trackOnboarding.ts new file mode 100644 index 0000000000..4fe41403e6 --- /dev/null +++ b/web/oss/src/components/Onboarding/utils/trackOnboarding.ts @@ -0,0 +1,33 @@ +import {getDefaultStore} from "jotai" + +import {posthogAtom} from "@/oss/lib/helpers/analytics/store/atoms" + +interface BasePayload { + total_tasks: number + completed_tasks: number + skipped_tasks: number +} + +const capture = (event: string, payload: Record) => { + const store = getDefaultStore() + const posthog = store.get(posthogAtom) + posthog?.capture?.(event, payload) +} + +export const trackOnboardingTaskCompleted = (payload: BasePayload & {task_id: string}) => { + capture("onboarding_task_completed", payload) +} + +export const trackOnboardingTaskSkipped = (payload: BasePayload & {task_id: string}) => { + capture("onboarding_task_skipped", payload) +} + +export const trackOnboardingAllTasksCompleted = (payload: BasePayload) => { + capture("onboarding_all_tasks_completed", payload) +} + +export const trackOnboardingGuideClosed = ( + payload: BasePayload & {close_reason: "manual" | "auto_all_done"}, +) => { + capture("onboarding_guide_closed", payload) +} diff --git a/web/oss/src/components/Playground/Components/MainLayout/index.tsx b/web/oss/src/components/Playground/Components/MainLayout/index.tsx index 123d3d2711..252fae448c 100644 --- a/web/oss/src/components/Playground/Components/MainLayout/index.tsx +++ b/web/oss/src/components/Playground/Components/MainLayout/index.tsx @@ -5,6 +5,7 @@ import {Typography, Button, Splitter} from "antd" import clsx from "clsx" import {useAtomValue} from "jotai" +import NextViewport from "@/oss/components/Onboarding/components/NextViewport" import {generationInputRowIdsAtom} from "@/oss/components/Playground/state/atoms/generationProperties" import {chatTurnIdsAtom} from "@/oss/state/generation/entities" import {appStatusAtom} from "@/oss/state/variant/atoms/appStatus" @@ -195,7 +196,8 @@ const PlaygroundMainView = ({className, isLoading = false, ...divProps}: MainLay key={`${isComparisonView ? "comparison" : "single"}-splitter-panel-runs`} > {isComparisonView && } -
- {/* This component renders Output component header section */} {isComparisonView ? (
@@ -239,7 +240,7 @@ const PlaygroundMainView = ({className, isLoading = false, ...divProps}: MainLay ) }) )} -
+
diff --git a/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationChat/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationChat/index.tsx index 455262c296..0eaa10939a 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationChat/index.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationChat/index.tsx @@ -1,8 +1,6 @@ import {Typography} from "antd" import clsx from "clsx" -import {useAtomValue} from "jotai" -import {atom} from "jotai" -import {useSetAtom} from "jotai" +import {atom, useAtomValue, useSetAtom} from "jotai" import LastTurnFooterControls from "@/oss/components/Playground/Components/ChatCommon/LastTurnFooterControls" import {isComparisonViewAtom} from "@/oss/components/Playground/state/atoms" @@ -41,7 +39,7 @@ const GenerationChat = ({variantId, viewAs}: GenerationChatProps) => { * meaning when there's */} {!!variantId && - inputRowIds.map((inputRowId) => ( + inputRowIds.map((inputRowId, index) => ( { isComparisonView, }, ])} + isPrimaryRow={index === 0} /> ))} @@ -89,12 +88,13 @@ const GenerationChat = ({variantId, viewAs}: GenerationChatProps) => { /> ) })} - {turnIds.map((turnId) => ( + {turnIds.map((turnId, index) => ( ))} {turnIds.length > 0 ? ( diff --git a/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationChatTurnNormalized/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationChatTurnNormalized/index.tsx index 8217746705..9d860e6ffa 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationChatTurnNormalized/index.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationChatTurnNormalized/index.tsx @@ -37,7 +37,8 @@ const GenerationChatTurnNormalized = ({ className, hideUserMessage = false, messageProps, -}: Props) => { + enableTourTargets = false, +}: Props & {enableTourTargets?: boolean}) => { const displayedVariantIds = useAtomValue(displayedVariantsAtom) const setAddTurn = useSetAtom(addChatTurnAtom) const runTurn = useSetAtom(runChatTurnAtom) @@ -103,6 +104,9 @@ const GenerationChatTurnNormalized = ({ displayAssistantValue, toolMessages.length > 0, ) + const outputPanelId = enableTourTargets ? "tour-playground-output-panel" : undefined + const resultUtilsId = enableTourTargets ? "tour-playground-result-utils" : undefined + const traceButtonId = enableTourTargets ? "tour-playground-trace-button" : undefined return (
@@ -115,6 +119,7 @@ const GenerationChatTurnNormalized = ({ messageOptionProps={{ allowFileUpload: true, }} + containerId={enableTourTargets ? "tour-chat-user-message" : undefined} messageProps={messageProps} /> ) : null} @@ -137,7 +142,16 @@ const GenerationChatTurnNormalized = ({ kind="assistant" className="w-full" headerClassName="border-0 border-b border-solid border-[rgba(5,23,41,0.06)]" - footer={result ? : null} + containerId={outputPanelId} + footer={ + result ? ( + + ) : null + } messageProps={messageProps} /> {variantId diff --git a/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationCompletion/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationCompletion/index.tsx index 2a84088951..a9b1056f31 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationCompletion/index.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationCompletion/index.tsx @@ -25,7 +25,7 @@ const GenerationCompletion = ({ // Use derived row IDs: returns normalized ids; for completion with none, exposes a virtual default id const inputRowIds = useAtomValue(generationInputRowIdsAtom) as string[] - const inputRowId = inputRowIds[0] || null + const primaryRowId = inputRowIds[0] || null // EFFICIENT MUTATION: Use dedicated mutation atom instead of complex useCallback logic const addNewInputRow = useSetAtom(inputRowIdsAtom) @@ -39,16 +39,18 @@ const GenerationCompletion = ({ {viewType === "comparison" ? ( ) : ( - (inputRowIds || []).map((rowIdItem) => ( + (inputRowIds || []).map((rowIdItem, index) => ( )) )} diff --git a/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationCompletionRow/DefaultView.tsx b/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationCompletionRow/DefaultView.tsx index 091ec205aa..ae720a7bfb 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationCompletionRow/DefaultView.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationCompletionRow/DefaultView.tsx @@ -24,6 +24,7 @@ interface Props { runRow: () => void cancelRow: () => void isBusy: boolean + enableTourTargets?: boolean } const DefaultView = ({ @@ -38,6 +39,7 @@ const DefaultView = ({ runRow, cancelRow, isBusy, + enableTourTargets = false, }: Props) => { const variableIds = useAtomValue( useMemo( @@ -49,6 +51,7 @@ const DefaultView = ({ return ( <>
))} - {!inputOnly && variableIds.length === 0 ? ( import("../GenerationResultUtils"), interface Props { result: any + resultUtilsTourId?: string + traceButtonTourId?: string } -export default function GenerationResponsePanel({result}: Props) { +export default function GenerationResponsePanel({ + result, + resultUtilsTourId, + traceButtonTourId, +}: Props) { const {toolData, isJSON, displayValue} = useMemo( () => deriveToolViewModelFromResult(result), [result], @@ -24,7 +30,14 @@ export default function GenerationResponsePanel({result}: Props) { } + footer={ + + } /> ) } @@ -39,7 +52,14 @@ export default function GenerationResponsePanel({result}: Props) { disabled className="w-full" editorClassName="min-h-4 [&_p:first-child]:!mt-0" - footer={} + footer={ + + } handleChange={() => undefined} /> ) diff --git a/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationCompletionRow/SingleView.tsx b/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationCompletionRow/SingleView.tsx index 6d4da7fa23..f47a319d08 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationCompletionRow/SingleView.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationCompletionRow/SingleView.tsx @@ -31,6 +31,7 @@ interface Props { runRow: () => void cancelRow: () => void containerClassName?: string + enableTourTargets?: boolean } const SingleView = ({ @@ -45,6 +46,7 @@ const SingleView = ({ runRow, cancelRow, containerClassName, + enableTourTargets = false, }: Props) => { const variableIds = useAtomValue( useMemo( @@ -52,6 +54,9 @@ const SingleView = ({ [rowId, variantId], ), ) as string[] + const outputPanelId = enableTourTargets ? "tour-playground-output-panel" : undefined + const resultUtilsId = enableTourTargets ? "tour-playground-result-utils" : undefined + const traceButtonId = enableTourTargets ? "tour-playground-trace-button" : undefined return (
-
+
{variableIds.length > 0 && ( <>
@@ -72,29 +80,24 @@ const SingleView = ({
- {variableIds.map((id) => { - return ( -
( +
+ - -
- ) - })} + rowId={rowId} + className={clsx(["*:!border-none"])} + // disabled={disableForCustom} + // placeholder={ + // disableForCustom + // ? "Insert a {{variable}} in your template to create an input." + // : "Enter value" + // } + editorProps={{enableTokens: false}} + /> +
+ ))}
)} @@ -130,7 +133,10 @@ const SingleView = ({ )}
-
+
{isBusy ? ( ) : !result ? ( @@ -138,7 +144,11 @@ const SingleView = ({ ) : result.error ? ( ) : result.response ? ( - + ) : null}
diff --git a/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationCompletionRow/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationCompletionRow/index.tsx index 33970492af..2a8bfc2965 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationCompletionRow/index.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationCompletionRow/index.tsx @@ -31,6 +31,7 @@ const GenerationCompletionRow = ({ view, disabled, forceSingle, + isPrimaryRow = false, ...props }: GenerationCompletionRowProps) => { const classes = useStyles() @@ -104,6 +105,8 @@ const GenerationCompletionRow = ({ await cancelTests({rowId, variantIds, reason: "user_cancelled"} as any) }, [cancelTests, displayedVariantIds, variantId, viewType, rowId]) + const enableTourTargets = Boolean(isPrimaryRow && viewType === "single" && !!variantId) + // Single view content return forceSingle || (viewType === "single" && view !== "focus" && variantId) ? ( ) : ( ) } diff --git a/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationCompletionRow/types.d.ts b/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationCompletionRow/types.d.ts index ca1c0737e4..cb4309177f 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationCompletionRow/types.d.ts +++ b/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationCompletionRow/types.d.ts @@ -5,4 +5,6 @@ export interface GenerationCompletionRowProps extends BaseContainerProps { rowId: string inputOnly?: boolean view?: string + isPrimaryVariant?: boolean + isPrimaryRow?: boolean } diff --git a/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationHeader/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationHeader/index.tsx index 9687ef87a4..3912261db8 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationHeader/index.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationHeader/index.tsx @@ -64,7 +64,7 @@ const GenerationHeader = ({variantId}: GenerationHeaderProps) => { return (
@@ -89,6 +89,7 @@ const GenerationHeader = ({variantId}: GenerationHeaderProps) => { {!isRunning ? ( runTests()} diff --git a/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationResultUtils/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationResultUtils/index.tsx index 9805def9b7..c37edbff0e 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationResultUtils/index.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationResultUtils/index.tsx @@ -17,6 +17,8 @@ const GenerationResultUtils: React.FC = ({ className, showStatus = true, result, + tourTargetId, + traceButtonTourId, }) => { const tree = result?.response?.tree const node = tree?.nodes?.[0] @@ -40,8 +42,18 @@ const GenerationResultUtils: React.FC = ({ const formattedCosts = useMemo(() => formatCurrency(costs), [costs]) return ( -
- +
+ {showStatus && } diff --git a/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationResultUtils/types.d.ts b/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationResultUtils/types.d.ts index 4d648c7710..e0031c9a14 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationResultUtils/types.d.ts +++ b/web/oss/src/components/Playground/Components/PlaygroundGenerations/assets/GenerationResultUtils/types.d.ts @@ -4,4 +4,6 @@ export interface GenerationResultUtilsProps { className?: string result: TestResult | null | undefined showStatus?: boolean + tourTargetId?: string + traceButtonTourId?: string } diff --git a/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx index fcc107a2ce..8850748331 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx @@ -137,7 +137,7 @@ const PlaygroundHeader: React.FC = ({
-
+
- {promptIds.map((promptId) => ( + {promptIds.map((promptId, index) => ( ))} diff --git a/web/oss/src/components/Playground/Components/PlaygroundVariantConfigPrompt/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundVariantConfigPrompt/index.tsx index 6c15b7ea89..4dc9fbec67 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundVariantConfigPrompt/index.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundVariantConfigPrompt/index.tsx @@ -32,12 +32,14 @@ const PlaygroundVariantConfigPrompt: React.FC { const defaultActiveKey = useRef(["1"]) const classes = useStyles() + const promptSectionId = enableTourTarget ? "tour-playground-prompt" : undefined const items = useMemo( () => [ @@ -70,11 +72,12 @@ const PlaygroundVariantConfigPrompt: React.FC ), }, ], - [variantId, promptId, viewOnly, disableCollapse], + [variantId, promptId, viewOnly, promptSectionId, disableCollapse], ) return ( diff --git a/web/oss/src/components/Playground/Components/PlaygroundVariantConfigPrompt/types.d.ts b/web/oss/src/components/Playground/Components/PlaygroundVariantConfigPrompt/types.d.ts index 9068b9aec9..ddd6a26493 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundVariantConfigPrompt/types.d.ts +++ b/web/oss/src/components/Playground/Components/PlaygroundVariantConfigPrompt/types.d.ts @@ -10,6 +10,8 @@ export interface PlaygroundVariantConfigPromptComponentProps extends CollapsePro promptId: string /** Whether the prompt is mutable or view only */ viewOnly?: boolean + /** Whether to expose onboarding target ids */ + enableTourTarget?: boolean /** * Disables collapsing behavior for the prompt panel (keeps the content shown). * Useful when the parent wants the prompt section to be always expanded. diff --git a/web/oss/src/components/Playground/adapters/TurnMessageAdapter.tsx b/web/oss/src/components/Playground/adapters/TurnMessageAdapter.tsx index 96af3ef6a1..45ab264127 100644 --- a/web/oss/src/components/Playground/adapters/TurnMessageAdapter.tsx +++ b/web/oss/src/components/Playground/adapters/TurnMessageAdapter.tsx @@ -31,6 +31,7 @@ interface Props { messageOptionProps?: Partial> toolCallsView?: {title?: string; json: string} | null toolIndex?: number + containerId?: string } const TurnMessageAdapter: React.FC = ({ @@ -53,6 +54,7 @@ const TurnMessageAdapter: React.FC = ({ handleRerun: propsHandleRerun, resultHashes: propsResultHashes, toolIndex = 0, + containerId, }) => { const editorIdRef = useRef(uuidv4()) const turn = useAtomValue(chatTurnsByIdFamilyAtom(rowId)) as any @@ -353,6 +355,7 @@ const TurnMessageAdapter: React.FC = ({ toolPayloads.map((p) => (
= ({ }, messageProps?.className, )} + id={containerId} > = ({ className: clsx({ "[&_.ant-menu-submenu-arrow]:hidden [&_.ant-menu-title-content]:hidden": collapsed, + "tour-help-docs-link": item.key === "help-docs-link", }), disabled: item.isCloudFeature || item.disabled, onTitleClick: item.onClick, @@ -90,6 +91,7 @@ const SidebarMenu: React.FC = ({ return ( { const {selectedOrg} = useOrgData() const {toggle, isVisible, isCrispEnabled} = useCrispChat() const {projectURL, baseAppURL, appURL, recentlyVisitedAppURL} = useURL() + const setWidgetMinimized = useSetAtom(onboardingWidgetMinimizedAtom) + const setWidgetClosed = useSetAtom(onboardingWidgetClosedAtom) const hasProjectURL = Boolean(projectURL) @@ -170,6 +177,16 @@ export const useSidebarConfig = () => { icon: , isBottom: true, submenu: [ + { + key: "onboarding-guide", + title: "Onboarding Guide", + icon: , + onClick: (e) => { + e.preventDefault() + setWidgetClosed(false) + setWidgetMinimized(false) + }, + }, { key: "docs", title: "Documentation", diff --git a/web/oss/src/components/pages/_app/index.tsx b/web/oss/src/components/pages/_app/index.tsx index 7411c7ebd7..de75122dc4 100644 --- a/web/oss/src/components/pages/_app/index.tsx +++ b/web/oss/src/components/pages/_app/index.tsx @@ -5,6 +5,7 @@ import {useAtomValue} from "jotai" import type {AppProps} from "next/app" import dynamic from "next/dynamic" import {Inter} from "next/font/google" +import {NextStepProvider} from "@agentaai/nextstepjs" import ThemeContextProvider from "@/oss/components/Layout/ThemeContextProvider" import GlobalScripts from "@/oss/components/Scripts/GlobalScripts" @@ -18,6 +19,7 @@ import ThemeContextBridge from "@/oss/ThemeContextBridge" import AppGlobalWrappers from "../../AppGlobalWrappers" import AppContextComponent from "../../AppMessageContext" +import CustomNextStepProvider from "../../Onboarding/components/CustomNextStepProvider" enableMapSet() @@ -51,31 +53,35 @@ export default function App({Component, pageProps, ...rest}: AppProps) {
- - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + +
) diff --git a/web/oss/src/components/pages/app-management/components/ApplicationManagementSection.tsx b/web/oss/src/components/pages/app-management/components/ApplicationManagementSection.tsx index 5dcc4966d3..c9330776f5 100644 --- a/web/oss/src/components/pages/app-management/components/ApplicationManagementSection.tsx +++ b/web/oss/src/components/pages/app-management/components/ApplicationManagementSection.tsx @@ -86,6 +86,7 @@ const ApplicationManagementSection = ({