Skip to content

Conversation

@mmabrouk
Copy link
Member

@mmabrouk mmabrouk commented Dec 31, 2025

Summary

  • Adds a scalable onboarding system for guided in-app tours
  • Uses @agentaai/nextstepjs (our fork with selector retry mechanism for async elements)
  • Includes one example tour for evaluation results page

Architecture

lib/onboarding/           # Core library
├── types.ts              # Type definitions
├── registry.ts           # Tour registry (singleton)
├── atoms.ts              # isNewUserAtom, seenToursAtom
└── README.md             # Documentation

components/Onboarding/    # React components
├── OnboardingProvider    # Provider wrapper
├── OnboardingCard        # Tour card UI
├── useOnboardingTour     # Hook for triggering tours
└── tours/                # Tour definitions

How to Use

  1. Define a tour:
const myTour = {
  id: "my-tour",
  steps: [
    { selector: "#element", title: "Welcome", content: "..." }
  ]
}
tourRegistry.register(myTour)
  1. Trigger it for new users:
useOnboardingTour({ tourId: "my-tour", autoStart: true })
  1. Enable onboarding after signup:
setIsNewUser(true)

Test Plan

  • Verify tour appears when isNewUserAtom is set to true
  • Verify tour is marked as seen after completion
  • Verify tour doesn't reappear after completion
  • Verify skip button works
CleanShot.2026-01-03.at.23.08.56.mp4

Add a scalable onboarding system for guided tours:

- Core library (lib/onboarding):
  - Tour registry pattern for modular tour registration
  - isNewUserAtom for controlling new user onboarding
  - seenToursAtom for tracking completed tours
  - Type definitions for steps and tours with lifecycle hooks

- Components (components/Onboarding):
  - OnboardingProvider wrapping NextStep library
  - OnboardingCard with progress bar and navigation
  - useOnboardingTour hook for triggering tours

- Example tour for evaluation results page

Uses @agentaai/nextstepjs (our fork with selector retry)
@vercel
Copy link

vercel bot commented Dec 31, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
agenta-documentation Ready Ready Preview, Comment Jan 3, 2026 10:17pm

@dosubot dosubot bot added the size:XXL This PR changes 1000+ lines, ignoring generated files. label Dec 31, 2025
@mmabrouk mmabrouk marked this pull request as draft December 31, 2025 15:25
@dosubot dosubot bot added the feature label Dec 31, 2025
…hydration issues

- Updated OnboardingProvider to use useState and useEffect for managing tour state, improving hydration handling.
- Added caching mechanism in TourRegistry to prevent unnecessary re-renders during tour registration and unregistration.
- Introduced InternalTour type for better type safety in onboarding components.
- Enhanced OnboardingCard to allow users to drag the card for better accessibility.
- Added drag state management using useState and useRef hooks.
- Implemented mouse event handlers for drag functionality, ensuring the card remains within viewport boundaries.
- Updated card rendering to reflect drag position and added a drag handle for user interaction.
… and interactions

- Changed tour step titles to better reflect content: "View Tabs" to "Aggregated Results" and "Explore Test Scenarios" to "Detailed Results".
- Enhanced content descriptions for clarity on tab functionalities.
- Adjusted selectors for the Overview and Scenarios tabs to improve targeting.
- Added functionality to automatically click the Scenarios tab when the corresponding step is shown.
…ponent

- Introduced a cardTransition prop with a duration of 0.2 seconds to enhance the visual experience during onboarding steps.
… component

- Store a reference to the current step's cleanup handler to ensure it is properly removed when leaving the step.
- Prevent accumulation of stale handlers by deleting the cleanup handler on exit.
- Removed unnecessary imports from EvalResultsOnboarding and useOnboardingTour files to streamline the codebase.
- Improved code readability by eliminating unused variables and dependencies.
- Consolidated import statements in the useOnboardingTour hook to improve code clarity and maintainability.
@mmabrouk mmabrouk requested a review from ardaerzin January 3, 2026 22:20
@mmabrouk mmabrouk marked this pull request as ready for review January 3, 2026 22:20
resetSeenToursAtom,
activeTourIdAtom,
currentStepStateAtom,
} from "./atoms"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's use reducer pattern.

something like:

import type {OnboardingStep} from "./types"

// ─────────────────────────────────────────────────────────────
// State
// ─────────────────────────────────────────────────────────────
export type TourStatus = "idle" | "active" | "completing"

export interface TourState {
    status: TourStatus
    activeTourId: string | null
    currentStepIndex: number
    totalSteps: number
    currentStep: OnboardingStep | null
    /** Pending effects to execute after state transition */
    pendingEffects: TourEffect[]
}

export const initialTourState: TourState = {
    status: "idle",
    activeTourId: null,
    currentStepIndex: 0,
    totalSteps: 0,
    currentStep: null,
    pendingEffects: [],
}

// ─────────────────────────────────────────────────────────────
// Actions (explicit, dispatchable intentions)
// ─────────────────────────────────────────────────────────────
export type TourAction =
    | { type: "START_TOUR"; tourId: string; steps: OnboardingStep[] }
    | { type: "NEXT_STEP" }
    | { type: "PREV_STEP" }
    | { type: "SKIP_TOUR" }
    | { type: "COMPLETE_TOUR" }
    | { type: "CLEAR_EFFECTS" }

// ─────────────────────────────────────────────────────────────
// Effects (side effects to run AFTER state updates)
// ─────────────────────────────────────────────────────────────
export type TourEffect =
    | { type: "RUN_ON_ENTER"; step: OnboardingStep }
    | { type: "RUN_ON_EXIT"; step: OnboardingStep }
    | { type: "RUN_ON_CLEANUP"; steps: OnboardingStep[] }
    | { type: "MARK_TOUR_SEEN"; tourId: string }
    | { type: "SYNC_LIBRARY"; action: "start" | "next" | "prev" | "stop"; tourId?: string }

// ─────────────────────────────────────────────────────────────
// Reducer (pure function - no side effects)
// ─────────────────────────────────────────────────────────────
export function tourReducer(state: TourState, action: TourAction): TourState {
    switch (action.type) {
        case "START_TOUR": {
            if (state.status === "active") {
                // Already running a tour - reject
                console.warn(`[Tour] Cannot start "${action.tourId}" - tour already active`)
                return state
            }
            
            const firstStep = action.steps[0] ?? null
            return {
                status: "active",
                activeTourId: action.tourId,
                currentStepIndex: 0,
                totalSteps: action.steps.length,
                currentStep: firstStep,
                pendingEffects: [
                    { type: "SYNC_LIBRARY", action: "start", tourId: action.tourId },
                    ...(firstStep ? [{ type: "RUN_ON_ENTER" as const, step: firstStep }] : []),
                ],
            }
        }

        case "NEXT_STEP": {
            if (state.status !== "active" || !state.activeTourId) return state

            const nextIndex = state.currentStepIndex + 1
            const steps = getStepsFromRegistry(state.activeTourId)
            
            // Check if this was the last step
            if (nextIndex >= state.totalSteps) {
                return {
                    ...state,
                    status: "completing",
                    pendingEffects: [
                        ...(state.currentStep ? [{ type: "RUN_ON_EXIT" as const, step: state.currentStep }] : []),
                        { type: "RUN_ON_CLEANUP", steps },
                        { type: "MARK_TOUR_SEEN", tourId: state.activeTourId },
                        { type: "SYNC_LIBRARY", action: "stop" },
                    ],
                }
            }

            const nextStep = steps[nextIndex] ?? null
            return {
                ...state,
                currentStepIndex: nextIndex,
                currentStep: nextStep,
                pendingEffects: [
                    ...(state.currentStep ? [{ type: "RUN_ON_EXIT" as const, step: state.currentStep }] : []),
                    { type: "SYNC_LIBRARY", action: "next" },
                    ...(nextStep ? [{ type: "RUN_ON_ENTER" as const, step: nextStep }] : []),
                ],
            }
        }

        case "PREV_STEP": {
            if (state.status !== "active" || state.currentStepIndex === 0) return state

            const prevIndex = state.currentStepIndex - 1
            const steps = getStepsFromRegistry(state.activeTourId!)
            const prevStep = steps[prevIndex] ?? null

            return {
                ...state,
                currentStepIndex: prevIndex,
                currentStep: prevStep,
                pendingEffects: [
                    ...(state.currentStep ? [{ type: "RUN_ON_EXIT" as const, step: state.currentStep }] : []),
                    { type: "SYNC_LIBRARY", action: "prev" },
                    ...(prevStep ? [{ type: "RUN_ON_ENTER" as const, step: prevStep }] : []),
                ],
            }
        }

        case "SKIP_TOUR": {
            if (state.status !== "active" || !state.activeTourId) return state

            const steps = getStepsFromRegistry(state.activeTourId)
            return {
                ...initialTourState,
                pendingEffects: [
                    ...(state.currentStep ? [{ type: "RUN_ON_EXIT" as const, step: state.currentStep }] : []),
                    { type: "RUN_ON_CLEANUP", steps },
                    { type: "MARK_TOUR_SEEN", tourId: state.activeTourId },
                    { type: "SYNC_LIBRARY", action: "stop" },
                ],
            }
        }

        case "COMPLETE_TOUR": {
            // Final cleanup after completing effects
            return initialTourState
        }

        case "CLEAR_EFFECTS": {
            return { ...state, pendingEffects: [] }
        }

        default:
            return state
    }
}

// Helper to get steps (would import from registry)
function getStepsFromRegistry(tourId: string): OnboardingStep[] {
    // Import tourRegistry and get steps
    const { tourRegistry } = require("./registry")
    return tourRegistry.get(tourId)?.steps ?? []
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and a hook to execute pending effects from the tour reducer, separating WHAT should happen (reducer) from HOW it happens (this new hook)

if (!canAutoStart) return

// Small delay to ensure page is rendered
const timer = setTimeout(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's not have arbitrary timeouts. reducer pattern can help us clean these up


// Update current step state and run lifecycle hooks
useEffect(() => {
if (!step) return
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can offload logic from this component to the reducer

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants