From 605edee033214683420a250504ace88cf3b08b9b Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 6 Jan 2026 21:37:49 +0530 Subject: [PATCH 1/7] feat: add onboarding tour component and integrate into dashboard layout - Introduced the OnboardingTour component to guide users through key features. - Integrated the OnboardingTour into the DashboardClientLayout for improved user experience. - Updated connector popup and sidebar navigation with data attributes for tour steps. --- .../[search_space_id]/client-layout.tsx | 2 + .../assistant-ui/connector-popup.tsx | 1 + surfsense_web/components/onboarding-tour.tsx | 523 ++++++++++++++++++ surfsense_web/components/sidebar/nav-main.tsx | 24 +- 4 files changed, 547 insertions(+), 3 deletions(-) create mode 100644 surfsense_web/components/onboarding-tour.tsx diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index 6e61ff7ac..c78cc7762 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -17,6 +17,7 @@ import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-quer import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup"; import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; +import { OnboardingTour } from "@/components/onboarding-tour"; import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; @@ -242,6 +243,7 @@ export function DashboardClientLayout({ return ( + {/* Use AppSidebarProvider which fetches user, search space, and recent chats */} { return ( + {/* Dark overlay with cutout using box-shadow technique */} +
+ {/* Blue shadow behind the button - starts from button border */} +
+ + ); +} + +function TourTooltip({ + step, + stepIndex, + totalSteps, + position, + targetRect, + onNext, + onPrev, + onSkip, + isDarkMode, +}: { + step: TourStep; + stepIndex: number; + totalSteps: number; + position: TooltipPosition; + targetRect: DOMRect; + onNext: () => void; + onPrev: () => void; + onSkip: () => void; + isDarkMode: boolean; +}) { + const isLastStep = stepIndex === totalSteps - 1; + const isFirstStep = stepIndex === 0; + + const bgColor = isDarkMode ? "#18181b" : "#18181b"; // Dark tooltip for both modes as shown in image + const textColor = "#ffffff"; + const mutedTextColor = "#a1a1aa"; + + // Calculate pointer line position + const getPointerStyles = (): React.CSSProperties => { + const lineLength = 16; + const dotSize = 6; + // Check if this is the documents step (stepIndex === 1) + const isDocumentsStep = stepIndex === 1; + + if (position.pointerPosition === "left") { + return { + position: "absolute", + left: -lineLength - dotSize, + top: isDocumentsStep ? "calc(50% - 8px)" : "50%", + transform: "translateY(-50%)", + display: "flex", + alignItems: "center", + }; + } + if (position.pointerPosition === "top") { + return { + position: "absolute", + top: -lineLength - dotSize, + left: "50%", + transform: "translateX(-50%)", + display: "flex", + flexDirection: "column", + alignItems: "center", + }; + } + return {}; + }; + + const renderPointer = () => { + const lineColor = "#18181B"; + + if (position.pointerPosition === "left") { + return ( +
+
+
+
+ ); + } + if (position.pointerPosition === "top") { + return ( +
+
+
+
+ ); + } + return null; + }; + + // Render step dots + const renderStepDots = () => { + return ( +
+ {Array.from({ length: totalSteps }).map((_, i) => ( +
+ ))} +
+ ); + }; + + return ( +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + {/* Pointer line */} + {renderPointer()} + +
+ {/* Content */} +
+

+ {step.title} +

+

+ {step.content} +

+
+ + {/* Footer */} +
+ {/* Step dots */} + {renderStepDots()} + + {/* Navigation buttons */} +
+ {!isFirstStep && ( + + )} + {isFirstStep && ( + + )} + +
+
+
+
+ ); +} + +export function OnboardingTour() { + const [isActive, setIsActive] = useState(false); + const [stepIndex, setStepIndex] = useState(0); + const [targetEl, setTargetEl] = useState(null); + const [position, setPosition] = useState(null); + const [targetRect, setTargetRect] = useState(null); + const [mounted, setMounted] = useState(false); + const { resolvedTheme } = useTheme(); + const retryCountRef = useRef(0); + const maxRetries = 10; + + const isDarkMode = resolvedTheme === "dark"; + const currentStep = TOUR_STEPS[stepIndex]; + + // Handle mounting for portal + useEffect(() => { + setMounted(true); + }, []); + + // Find and track target element with retry logic + const updateTarget = useCallback(() => { + if (!currentStep) return; + + const el = document.querySelector(currentStep.target); + if (el) { + setTargetEl(el); + setTargetRect(el.getBoundingClientRect()); + setPosition(calculatePosition(el, currentStep.placement)); + retryCountRef.current = 0; + } else if (retryCountRef.current < maxRetries) { + retryCountRef.current++; + setTimeout(() => { + const retryEl = document.querySelector(currentStep.target); + if (retryEl) { + setTargetEl(retryEl); + setTargetRect(retryEl.getBoundingClientRect()); + setPosition(calculatePosition(retryEl, currentStep.placement)); + retryCountRef.current = 0; + } + }, 200); + } + }, [currentStep]); + + // Start tour and find first target + useEffect(() => { + const timer = setTimeout(() => { + const el = document.querySelector(TOUR_STEPS[0].target); + if (el) { + setIsActive(true); + setTargetEl(el); + setTargetRect(el.getBoundingClientRect()); + setPosition(calculatePosition(el, TOUR_STEPS[0].placement)); + } + }, 1000); + + return () => clearTimeout(timer); + }, []); + + // Update position on resize/scroll + useEffect(() => { + if (!isActive || !targetEl) return; + + const handleUpdate = () => { + const rect = targetEl.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + setTargetRect(rect); + setPosition(calculatePosition(targetEl, currentStep?.placement || "bottom")); + } + }; + + window.addEventListener("resize", handleUpdate); + window.addEventListener("scroll", handleUpdate, true); + + return () => { + window.removeEventListener("resize", handleUpdate); + window.removeEventListener("scroll", handleUpdate, true); + }; + }, [isActive, targetEl, currentStep?.placement]); + + // Update target when step changes + useEffect(() => { + if (isActive && currentStep) { + const timer = setTimeout(() => { + updateTarget(); + }, 100); + return () => clearTimeout(timer); + } + }, [isActive, updateTarget, currentStep]); + + // Ensure target element is above overlay layers so content is fully visible + useEffect(() => { + if (!targetEl || !isActive) return; + + const originalZIndex = (targetEl as HTMLElement).style.zIndex; + const originalPosition = (targetEl as HTMLElement).style.position; + + // Ensure the element has a position that allows z-index + if (getComputedStyle(targetEl).position === "static") { + (targetEl as HTMLElement).style.position = "relative"; + } + (targetEl as HTMLElement).style.zIndex = "99999"; + + return () => { + (targetEl as HTMLElement).style.zIndex = originalZIndex; + if (originalPosition) { + (targetEl as HTMLElement).style.position = originalPosition; + } else if (getComputedStyle(targetEl).position === "relative" && originalPosition === "") { + (targetEl as HTMLElement).style.position = ""; + } + }; + }, [targetEl, isActive]); + + const handleNext = useCallback(() => { + if (stepIndex < TOUR_STEPS.length - 1) { + retryCountRef.current = 0; + setStepIndex(stepIndex + 1); + } else { + setIsActive(false); + } + }, [stepIndex]); + + const handlePrev = useCallback(() => { + if (stepIndex > 0) { + retryCountRef.current = 0; + setStepIndex(stepIndex - 1); + } + }, [stepIndex]); + + const handleSkip = useCallback(() => { + setIsActive(false); + }, []); + + // Handle overlay click to close + const handleOverlayClick = useCallback(() => { + setIsActive(false); + }, []); + + // Handle escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && isActive) { + setIsActive(false); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isActive]); + + // Don't render if not active or not mounted + if (!mounted || !isActive || !targetEl || !position || !currentStep || !targetRect) { + return null; + } + + return createPortal( +
+ {/* Clickable backdrop to close */} +
, + document.body + ); +} diff --git a/surfsense_web/components/sidebar/nav-main.tsx b/surfsense_web/components/sidebar/nav-main.tsx index 43c551875..a0dbe912f 100644 --- a/surfsense_web/components/sidebar/nav-main.tsx +++ b/surfsense_web/components/sidebar/nav-main.tsx @@ -131,7 +131,11 @@ export function NavMain({ items }: NavMainProps) { isActive={isActive} aria-label={`${translatedTitle} with submenu`} > - @@ -152,10 +156,18 @@ export function NavMain({ items }: NavMainProps) { {item.items?.map((subItem, subIndex) => { const translatedSubTitle = translateTitle(subItem.title); + const isDocumentsLink = + subItem.title === "Manage Documents" || + translatedSubTitle.toLowerCase().includes("documents"); return ( - + {translatedSubTitle} @@ -173,7 +185,13 @@ export function NavMain({ items }: NavMainProps) { isActive={isActive} aria-label={translatedTitle} > - + {translatedTitle} From e46a0e0a9545280460445a52be4547167f565cf2 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 6 Jan 2026 22:14:11 +0530 Subject: [PATCH 2/7] fix: improve onboarding tour functionality and UI - Updated onboarding tour messages to include Gmail and Drive for better clarity. - Refactored Spotlight component to enhance element targeting and prevent flickering. - Optimized rendering logic for Spotlight and TourTooltip to ensure they only display when target data is available. --- surfsense_web/components/onboarding-tour.tsx | 70 +++++++++++++------- 1 file changed, 46 insertions(+), 24 deletions(-) diff --git a/surfsense_web/components/onboarding-tour.tsx b/surfsense_web/components/onboarding-tour.tsx index 077a93be3..1e7be0f44 100644 --- a/surfsense_web/components/onboarding-tour.tsx +++ b/surfsense_web/components/onboarding-tour.tsx @@ -16,7 +16,7 @@ const TOUR_STEPS: TourStep[] = [ target: '[data-joyride="connector-icon"]', title: "Connect your data sources", content: - "Click this icon to connect and sync data from GitHub, Slack, Notion, Jira, Confluence, and more.", + "Connect and sync data from Gmail, Drive, Slack, Notion, Jira, Confluence, and more.", placement: "bottom", }, { @@ -78,18 +78,21 @@ function calculatePosition(targetEl: Element, placement: TourStep["placement"]): function Spotlight({ targetEl, isDarkMode, - stepIndex, + currentStepTarget, }: { targetEl: Element; isDarkMode: boolean; - stepIndex: number; + currentStepTarget: string; }) { const rect = targetEl.getBoundingClientRect(); const padding = 6; const shadowColor = isDarkMode ? "#172554" : "#0c1a3a"; - // Check if this is the connector icon step (stepIndex === 0) - const isConnectorStep = stepIndex === 0; + // Check if this is the connector icon step - verify both the selector matches AND the element matches + // This prevents the shape from changing before targetEl updates + const isConnectorSelector = currentStepTarget === '[data-joyride="connector-icon"]'; + const isConnectorElement = targetEl.matches('[data-joyride="connector-icon"]'); + const isConnectorStep = isConnectorSelector && isConnectorElement; // For circle, use the larger dimension to ensure it's a perfect circle const circleSize = isConnectorStep ? Math.max(rect.width, rect.height) : 0; @@ -135,7 +138,6 @@ function TourTooltip({ stepIndex, totalSteps, position, - targetRect, onNext, onPrev, onSkip, @@ -243,7 +245,7 @@ function TourTooltip({
{Array.from({ length: totalSteps }).map((_, i) => (
{ if (isActive && currentStep) { - const timer = setTimeout(() => { - updateTarget(); - }, 100); - return () => clearTimeout(timer); + // Try to find element synchronously first to prevent any delay + const el = document.querySelector(currentStep.target); + if (el) { + // Found immediately - update state synchronously to prevent flicker + const rect = el.getBoundingClientRect(); + const newPosition = calculatePosition(el, currentStep.placement); + // React 18+ automatically batches these updates + setTargetEl(el); + setTargetRect(rect); + setPosition(newPosition); + retryCountRef.current = 0; + } else { + // Not found immediately, use updateTarget with retry logic + // Use requestAnimationFrame to batch with next paint + const frameId = requestAnimationFrame(() => { + updateTarget(); + }); + return () => cancelAnimationFrame(frameId); + } } }, [isActive, updateTarget, currentStep]); @@ -492,7 +509,7 @@ export function OnboardingTour() { }, [isActive]); // Don't render if not active or not mounted - if (!mounted || !isActive || !targetEl || !position || !currentStep || !targetRect) { + if (!mounted || !isActive) { return null; } @@ -505,18 +522,23 @@ export function OnboardingTour() { onClick={handleOverlayClick} aria-label="Close tour" /> - - + {/* Only render Spotlight and TourTooltip when we have target data */} + {targetEl && position && currentStep && targetRect && ( + <> + + + + )}
, document.body ); From 0621304fbde9ceb97957db6d4e7c3e7c5813fad8 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 6 Jan 2026 22:43:59 +0530 Subject: [PATCH 3/7] refactor: enhance onboarding tour UI and functionality - Updated tooltip and spotlight styles for improved visibility and animation. - Adjusted background and text colors based on dark mode settings for better user experience. - Introduced animation for tooltip content changes to enhance user engagement. - Refactored rendering logic to ensure spotlight updates sync with tooltip animations. --- surfsense_web/components/onboarding-tour.tsx | 151 ++++++++++++++----- 1 file changed, 113 insertions(+), 38 deletions(-) diff --git a/surfsense_web/components/onboarding-tour.tsx b/surfsense_web/components/onboarding-tour.tsx index 1e7be0f44..19d616237 100644 --- a/surfsense_web/components/onboarding-tour.tsx +++ b/surfsense_web/components/onboarding-tour.tsx @@ -15,8 +15,7 @@ const TOUR_STEPS: TourStep[] = [ { target: '[data-joyride="connector-icon"]', title: "Connect your data sources", - content: - "Connect and sync data from Gmail, Drive, Slack, Notion, Jira, Confluence, and more.", + content: "Connect and sync data from Gmail, Drive, Slack, Notion, Jira, Confluence, and more.", placement: "bottom", }, { @@ -86,7 +85,7 @@ function Spotlight({ }) { const rect = targetEl.getBoundingClientRect(); const padding = 6; - const shadowColor = isDarkMode ? "#172554" : "#0c1a3a"; + const shadowColor = isDarkMode ? "#172554" : "#3b82f6"; // Check if this is the connector icon step - verify both the selector matches AND the element matches // This prevents the shape from changing before targetEl updates @@ -110,7 +109,9 @@ function Spotlight({ width: isConnectorStep ? circleSize + padding * 2 : rect.width + padding * 2, height: isConnectorStep ? circleSize + padding * 2 : rect.height + padding * 2, borderRadius: isConnectorStep ? "50%" : 8, - boxShadow: `0 0 0 9999px rgba(0, 0, 0, 0.6)`, + boxShadow: isDarkMode + ? `0 0 0 9999px rgba(0, 0, 0, 0.6)` + : `0 0 0 9999px rgba(0, 0, 0, 0.3)`, backgroundColor: "transparent", zIndex: 99996, }} @@ -124,7 +125,9 @@ function Spotlight({ width: isConnectorStep ? circleSize : rect.width, height: isConnectorStep ? circleSize : rect.height, borderRadius: isConnectorStep ? "50%" : 8, - boxShadow: `0 0 10px 2px ${shadowColor}CC, 0 0 20px 6px ${shadowColor}99, 0 0 40px 12px ${shadowColor}66`, + boxShadow: isDarkMode + ? `0 0 10px 2px ${shadowColor}CC, 0 0 20px 6px ${shadowColor}99, 0 0 40px 12px ${shadowColor}66` + : `0 0 6px 1px ${shadowColor}80, 0 0 12px 3px ${shadowColor}50, 0 0 20px 6px ${shadowColor}30`, backgroundColor: "transparent", zIndex: 99997, }} @@ -153,12 +156,25 @@ function TourTooltip({ onSkip: () => void; isDarkMode: boolean; }) { + const [contentKey, setContentKey] = useState(stepIndex); + const [shouldAnimate, setShouldAnimate] = useState(false); + const prevStepIndexRef = useRef(stepIndex); const isLastStep = stepIndex === totalSteps - 1; const isFirstStep = stepIndex === 0; - const bgColor = isDarkMode ? "#18181b" : "#18181b"; // Dark tooltip for both modes as shown in image - const textColor = "#ffffff"; - const mutedTextColor = "#a1a1aa"; + // Update content key when step changes to trigger animation + // Only animate if stepIndex actually changes (not on initial mount) + useEffect(() => { + if (prevStepIndexRef.current !== stepIndex) { + setShouldAnimate(true); + setContentKey(stepIndex); + prevStepIndexRef.current = stepIndex; + } + }, [stepIndex]); + + const bgColor = isDarkMode ? "#18181b" : "#ffffff"; + const textColor = isDarkMode ? "#ffffff" : "#18181b"; + const mutedTextColor = isDarkMode ? "#a1a1aa" : "#71717a"; // Calculate pointer line position const getPointerStyles = (): React.CSSProperties => { @@ -192,7 +208,7 @@ function TourTooltip({ }; const renderPointer = () => { - const lineColor = "#18181B"; + const lineColor = isDarkMode ? "#18181B" : "#ffffff"; if (position.pointerPosition === "left") { return ( @@ -250,7 +266,14 @@ function TourTooltip({ width: 6, height: 6, borderRadius: "50%", - backgroundColor: i === stepIndex ? "#ffffff" : "#52525b", + backgroundColor: + i === stepIndex + ? isDarkMode + ? "#ffffff" + : "#18181b" + : isDarkMode + ? "#52525b" + : "#d4d4d8", transition: "background-color 0.2s", }} /> @@ -269,6 +292,7 @@ function TourTooltip({ top: position.top, left: position.left, width: 280, + transition: "top 0.4s cubic-bezier(0.4, 0, 0.2, 1), left 0.4s cubic-bezier(0.4, 0, 0.2, 1)", }} onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} @@ -281,11 +305,19 @@ function TourTooltip({ style={{ backgroundColor: bgColor, color: textColor, - boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)", + boxShadow: isDarkMode + ? "0 25px 50px -12px rgba(0, 0, 0, 0.5)" + : "0 25px 50px -12px rgba(0, 0, 0, 0.15)", }} > {/* Content */} -
+
setShouldAnimate(false)} + >

{step.title}

@@ -349,6 +381,8 @@ export function OnboardingTour() { const [isActive, setIsActive] = useState(false); const [stepIndex, setStepIndex] = useState(0); const [targetEl, setTargetEl] = useState(null); + const [spotlightTargetEl, setSpotlightTargetEl] = useState(null); + const [spotlightStepTarget, setSpotlightStepTarget] = useState(null); const [position, setPosition] = useState(null); const [targetRect, setTargetRect] = useState(null); const [mounted, setMounted] = useState(false); @@ -395,6 +429,8 @@ export function OnboardingTour() { if (el) { setIsActive(true); setTargetEl(el); + setSpotlightTargetEl(el); + setSpotlightStepTarget(TOUR_STEPS[0].target); setTargetRect(el.getBoundingClientRect()); setPosition(calculatePosition(el, TOUR_STEPS[0].placement)); } @@ -449,6 +485,17 @@ export function OnboardingTour() { } }, [isActive, updateTarget, currentStep]); + // Delay spotlight update to sync with tooltip animation + useEffect(() => { + if (targetEl && currentStep) { + const timer = setTimeout(() => { + setSpotlightTargetEl(targetEl); + setSpotlightStepTarget(currentStep.target); + }, 100); + return () => clearTimeout(timer); + } + }, [targetEl, currentStep]); + // Ensure target element is above overlay layers so content is fully visible useEffect(() => { if (!targetEl || !isActive) return; @@ -514,32 +561,60 @@ export function OnboardingTour() { } return createPortal( -
- {/* Clickable backdrop to close */} -
, + <> + +
+ {/* Clickable backdrop to close */} +
+ , document.body ); } From 933917d8fd7c963735a797993748ec167ce39a67 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 6 Jan 2026 22:59:40 +0530 Subject: [PATCH 4/7] feat: enhance onboarding tour logic and data handling - Integrated user data fetching and validation to determine if the onboarding tour should be displayed. - Improved checks for user status by evaluating threads, documents, and connectors to identify new users. - Added localStorage management to track whether users have completed or skipped the tour. - Refactored the tour initiation logic to ensure it only runs when all necessary data is loaded and available. --- surfsense_web/components/onboarding-tour.tsx | 142 ++++++++++++++++--- 1 file changed, 125 insertions(+), 17 deletions(-) diff --git a/surfsense_web/components/onboarding-tour.tsx b/surfsense_web/components/onboarding-tour.tsx index 19d616237..1f01d8ad4 100644 --- a/surfsense_web/components/onboarding-tour.tsx +++ b/surfsense_web/components/onboarding-tour.tsx @@ -1,8 +1,16 @@ "use client"; +import { useAtomValue } from "jotai"; +import { useQuery } from "@tanstack/react-query"; +import { usePathname } from "next/navigation"; import { useTheme } from "next-themes"; import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; +import { currentUserAtom } from "@/atoms/user/user-query.atoms"; +import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; +import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; +import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; +import { fetchThreads } from "@/lib/chat/thread-persistence"; interface TourStep { target: string; @@ -387,9 +395,27 @@ export function OnboardingTour() { const [targetRect, setTargetRect] = useState(null); const [mounted, setMounted] = useState(false); const { resolvedTheme } = useTheme(); + const pathname = usePathname(); const retryCountRef = useRef(0); const maxRetries = 10; + // Get user data + const { data: user } = useAtomValue(currentUserAtom); + const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); + + // Fetch threads data + const { data: threadsData } = useQuery({ + queryKey: ["threads", searchSpaceId], + queryFn: () => fetchThreads(Number(searchSpaceId), 1), // Only need to check if any exist + enabled: !!searchSpaceId, + }); + + // Get document type counts + const { data: documentTypeCounts } = useAtomValue(documentTypeCountsAtom); + + // Get connectors + const { data: connectors = [] } = useAtomValue(connectorsAtom); + const isDarkMode = resolvedTheme === "dark"; const currentStep = TOUR_STEPS[stepIndex]; @@ -422,22 +448,84 @@ export function OnboardingTour() { } }, [currentStep]); - // Start tour and find first target + // Check if tour should run: localStorage + data validation useEffect(() => { - const timer = setTimeout(() => { - const el = document.querySelector(TOUR_STEPS[0].target); - if (el) { - setIsActive(true); - setTargetEl(el); - setSpotlightTargetEl(el); - setSpotlightStepTarget(TOUR_STEPS[0].target); - setTargetRect(el.getBoundingClientRect()); - setPosition(calculatePosition(el, TOUR_STEPS[0].placement)); - } - }, 1000); + // Don't check if not mounted or no user + if (!mounted || !user?.id || !searchSpaceId) return; + // Check if on new-chat page + const isNewChatPage = pathname?.includes("/new-chat"); + if (!isNewChatPage) return; + + // Wait for all data to be loaded before making decision + // Data is considered loaded when: + // - threadsData is defined (query completed, even if empty) + // - documentTypeCounts is defined (query completed, even if empty object) + // - connectors is an array (always defined with default []) + // If searchSpaceId is not set, connectors query won't run, but that's okay + const dataLoaded = threadsData !== undefined && documentTypeCounts !== undefined; + if (!dataLoaded) return; + + // Check localStorage first (fast check) + const tourKey = `surfsense-tour-${user.id}`; + const hasSeenTour = localStorage.getItem(tourKey); + if (hasSeenTour === "true") { + return; // User has seen tour, don't show + } + + // Validate user is actually new (reliable check) + const threads = threadsData?.threads ?? []; + const hasThreads = threads.length > 0; + + // Check document counts - sum all document type counts + const totalDocuments = documentTypeCounts + ? Object.values(documentTypeCounts).reduce((sum, count) => sum + count, 0) + : 0; + const hasDocuments = totalDocuments > 0; + + const hasConnectors = connectors.length > 0; + + // User is new if they have no threads, documents, or connectors + const isNewUser = !hasThreads && !hasDocuments && !hasConnectors; + + // If user has data but localStorage was cleared, mark as seen + if (!isNewUser) { + localStorage.setItem(tourKey, "true"); + return; + } + + // User is new and hasn't seen tour - wait for DOM elements and start tour + const checkAndStartTour = () => { + // Check if both required elements exist + const connectorEl = document.querySelector(TOUR_STEPS[0].target); + const documentsEl = document.querySelector(TOUR_STEPS[1].target); + + if (connectorEl && documentsEl) { + // Both elements found, start tour + setIsActive(true); + setTargetEl(connectorEl); + setSpotlightTargetEl(connectorEl); + setSpotlightStepTarget(TOUR_STEPS[0].target); + setTargetRect(connectorEl.getBoundingClientRect()); + setPosition(calculatePosition(connectorEl, TOUR_STEPS[0].placement)); + } else { + // Retry after delay + setTimeout(checkAndStartTour, 200); + } + }; + + // Start checking after initial delay + const timer = setTimeout(checkAndStartTour, 500); return () => clearTimeout(timer); - }, []); + }, [ + mounted, + user?.id, + searchSpaceId, + pathname, + threadsData, + documentTypeCounts, + connectors, + ]); // Update position on resize/scroll useEffect(() => { @@ -524,9 +612,14 @@ export function OnboardingTour() { retryCountRef.current = 0; setStepIndex(stepIndex + 1); } else { + // Tour completed - save to localStorage + if (user?.id) { + const tourKey = `surfsense-tour-${user.id}`; + localStorage.setItem(tourKey, "true"); + } setIsActive(false); } - }, [stepIndex]); + }, [stepIndex, user?.id]); const handlePrev = useCallback(() => { if (stepIndex > 0) { @@ -536,24 +629,39 @@ export function OnboardingTour() { }, [stepIndex]); const handleSkip = useCallback(() => { + // Tour skipped - save to localStorage + if (user?.id) { + const tourKey = `surfsense-tour-${user.id}`; + localStorage.setItem(tourKey, "true"); + } setIsActive(false); - }, []); + }, [user?.id]); // Handle overlay click to close const handleOverlayClick = useCallback(() => { + // Tour closed - save to localStorage + if (user?.id) { + const tourKey = `surfsense-tour-${user.id}`; + localStorage.setItem(tourKey, "true"); + } setIsActive(false); - }, []); + }, [user?.id]); // Handle escape key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape" && isActive) { + // Tour closed via escape - save to localStorage + if (user?.id) { + const tourKey = `surfsense-tour-${user.id}`; + localStorage.setItem(tourKey, "true"); + } setIsActive(false); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [isActive]); + }, [isActive, user?.id]); // Don't render if not active or not mounted if (!mounted || !isActive) { From e08eb7920cfbc0fed3c4f1233d100255352f9264 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 6 Jan 2026 23:01:28 +0530 Subject: [PATCH 5/7] fix: update the content for document --- surfsense_web/components/onboarding-tour.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surfsense_web/components/onboarding-tour.tsx b/surfsense_web/components/onboarding-tour.tsx index 1f01d8ad4..8711652f7 100644 --- a/surfsense_web/components/onboarding-tour.tsx +++ b/surfsense_web/components/onboarding-tour.tsx @@ -29,7 +29,7 @@ const TOUR_STEPS: TourStep[] = [ { target: '[data-joyride="documents-sidebar"]', title: "Manage your documents", - content: "Access and manage all your uploaded documents from the sidebar.", + content: "Access and manage all your uploaded documents.", placement: "right", }, ]; From 6939eb975d8e7ea5a2ab06d40c686933ad4a5b3c Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 6 Jan 2026 23:03:51 +0530 Subject: [PATCH 6/7] chore: ran frontend lint --- surfsense_web/components/onboarding-tour.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/surfsense_web/components/onboarding-tour.tsx b/surfsense_web/components/onboarding-tour.tsx index 8711652f7..9f7a95bba 100644 --- a/surfsense_web/components/onboarding-tour.tsx +++ b/surfsense_web/components/onboarding-tour.tsx @@ -517,15 +517,7 @@ export function OnboardingTour() { // Start checking after initial delay const timer = setTimeout(checkAndStartTour, 500); return () => clearTimeout(timer); - }, [ - mounted, - user?.id, - searchSpaceId, - pathname, - threadsData, - documentTypeCounts, - connectors, - ]); + }, [mounted, user?.id, searchSpaceId, pathname, threadsData, documentTypeCounts, connectors]); // Update position on resize/scroll useEffect(() => { From 9f48f22d2893d83d633fa724f8b04c5de114abc2 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 7 Jan 2026 01:43:11 +0530 Subject: [PATCH 7/7] feat: enhance onboarding tour user ID tracking - Added logic to track previous user ID to detect changes and reset tour state accordingly. - Updated localStorage checks to ensure the onboarding tour is displayed only for new users who haven't seen it. - Improved validation logic to prevent auto-marking the tour as seen if the user has existing data. --- surfsense_web/components/onboarding-tour.tsx | 36 ++++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/surfsense_web/components/onboarding-tour.tsx b/surfsense_web/components/onboarding-tour.tsx index 9f7a95bba..0fc43160a 100644 --- a/surfsense_web/components/onboarding-tour.tsx +++ b/surfsense_web/components/onboarding-tour.tsx @@ -398,6 +398,8 @@ export function OnboardingTour() { const pathname = usePathname(); const retryCountRef = useRef(0); const maxRetries = 10; + // Track previous user ID to detect user changes + const previousUserIdRef = useRef(null); // Get user data const { data: user } = useAtomValue(currentUserAtom); @@ -448,7 +450,7 @@ export function OnboardingTour() { } }, [currentStep]); - // Check if tour should run: localStorage + data validation + // Check if tour should run: localStorage + data validation with user ID tracking useEffect(() => { // Don't check if not mounted or no user if (!mounted || !user?.id || !searchSpaceId) return; @@ -466,11 +468,31 @@ export function OnboardingTour() { const dataLoaded = threadsData !== undefined && documentTypeCounts !== undefined; if (!dataLoaded) return; - // Check localStorage first (fast check) - const tourKey = `surfsense-tour-${user.id}`; + const currentUserId = user.id; + const previousUserId = previousUserIdRef.current; + + // Detect user change - if user ID changed, reset tour state + if (previousUserId !== null && previousUserId !== currentUserId) { + // User changed - reset tour state and re-evaluate for new user + setIsActive(false); + setStepIndex(0); + setTargetEl(null); + setSpotlightTargetEl(null); + setSpotlightStepTarget(null); + setPosition(null); + setTargetRect(null); + retryCountRef.current = 0; + } + + // Update previous user ID ref + previousUserIdRef.current = currentUserId; + + // Check localStorage for CURRENT user ID (not stale cache) + // This ensures we check the correct user's tour status + const tourKey = `surfsense-tour-${currentUserId}`; const hasSeenTour = localStorage.getItem(tourKey); if (hasSeenTour === "true") { - return; // User has seen tour, don't show + return; // Current user has seen tour, don't show } // Validate user is actually new (reliable check) @@ -488,10 +510,10 @@ export function OnboardingTour() { // User is new if they have no threads, documents, or connectors const isNewUser = !hasThreads && !hasDocuments && !hasConnectors; - // If user has data but localStorage was cleared, mark as seen + // Only show tour if user is new and hasn't seen it + // Don't auto-mark as seen if user has data - let them explicitly dismiss it if (!isNewUser) { - localStorage.setItem(tourKey, "true"); - return; + return; // User has data, don't show tour } // User is new and hasn't seen tour - wait for DOM elements and start tour