diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index 613ee3f2d..40ad10d4b 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -123,6 +123,7 @@ import { useWorkspaceQuotaStatus, } from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus'; import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip'; +import { SidebarGuide } from './SidebarGuide'; // Compare two version strings, returns true if v1 > v2 function compareVersions(v1: string, v2: string): boolean { @@ -588,7 +589,7 @@ function NavItems({ } // Non-entity entries (e.g. monitoring and the extension market) render as plain links. return ( - + onChildClick(config)} @@ -1017,7 +1018,7 @@ function NavItems({ // Popover flyout for collapsed sidebar if (showPopover) { return ( - + @@ -1199,7 +1200,7 @@ function NavItems({ onOpenChange={(open) => onSectionToggle(config.id, open)} className="group/collapsible" > - + {/* Models entry */} - + openSettings('models')} tooltip={t('models.title')} @@ -2224,7 +2225,7 @@ export default function HomeSidebar({ {/* API-key management is available only to authorized Workspace roles. */} {currentWorkspace?.permissions.includes('api_key.manage') && ( - + openSettings('apiIntegration')} tooltip={t('common.apiIntegration')} @@ -2456,6 +2457,7 @@ export default function HomeSidebar({ onOpenChange={setVersionDialogOpen} release={latestRelease} /> + ); } diff --git a/web/src/app/home/components/home-sidebar/SidebarGuide.tsx b/web/src/app/home/components/home-sidebar/SidebarGuide.tsx new file mode 100644 index 000000000..664342065 --- /dev/null +++ b/web/src/app/home/components/home-sidebar/SidebarGuide.tsx @@ -0,0 +1,359 @@ +import { createPortal } from 'react-dom'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Check, ChevronRight } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { useSidebar } from '@/components/ui/sidebar'; + +const SIDEBAR_GUIDE_STORAGE_KEY = 'langbot_sidebar_guide_v1'; +const MIN_POPOVER_WIDTH = 196; +const MAX_POPOVER_WIDTH = 320; +const VIEWPORT_GAP = 12; + +const GUIDE_STEP_IDS = [ + 'monitoring', + 'bots', + 'pipelines', + 'knowledge', + 'plugins', + 'add-extension', + 'models', + 'api-integration', +] as const; + +type GuideStepId = (typeof GUIDE_STEP_IDS)[number]; + +type TargetRect = { + top: number; + right: number; + bottom: number; + left: number; + width: number; + height: number; +}; + +type PopoverPosition = { + left: number; + top: number; + width: number; +}; + +function loadStoredStep(): number | null { + if (typeof window === 'undefined') return null; + + try { + const stored = localStorage.getItem(SIDEBAR_GUIDE_STORAGE_KEY); + if (stored === 'completed') return null; + + const parsed = Number.parseInt(stored ?? '0', 10); + if (!Number.isFinite(parsed) || parsed < 0) return 0; + return Math.min(parsed, GUIDE_STEP_IDS.length - 1); + } catch { + return 0; + } +} + +function storeGuideProgress(value: string) { + try { + localStorage.setItem(SIDEBAR_GUIDE_STORAGE_KEY, value); + } catch { + // The guide remains usable when browser storage is unavailable. + } +} + +function getTarget(stepId: GuideStepId): HTMLElement | null { + if (typeof document === 'undefined') return null; + return document.querySelector( + `[data-sidebar-guide="${stepId}"]`, + ); +} + +function findAvailableStep(startIndex: number): number | null { + for (let index = startIndex; index < GUIDE_STEP_IDS.length; index += 1) { + const target = getTarget(GUIDE_STEP_IDS[index]); + if (target) return index; + } + return null; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), Math.max(min, max)); +} + +export function SidebarGuide() { + const { t } = useTranslation(); + const { isMobile, open, openMobile, setOpen, setOpenMobile } = useSidebar(); + const [stepIndex, setStepIndex] = useState(loadStoredStep); + const [targetRect, setTargetRect] = useState(null); + const [popoverPosition, setPopoverPosition] = + useState(null); + const popoverRef = useRef(null); + const confirmButtonRef = useRef(null); + const originalSidebarStateRef = useRef<{ + open: boolean; + openMobile: boolean; + } | null>(null); + + const activeStepId = + stepIndex === null ? null : (GUIDE_STEP_IDS[stepIndex] ?? null); + + const visibleStepCount = GUIDE_STEP_IDS.filter((stepId) => + getTarget(stepId), + ).length; + + const visibleStepNumber = activeStepId + ? GUIDE_STEP_IDS.slice(0, stepIndex ?? 0).filter((stepId) => + getTarget(stepId), + ).length + 1 + : 0; + const isPopoverReady = popoverPosition !== null; + + const restoreSidebarState = useCallback(() => { + const original = originalSidebarStateRef.current; + if (!original) return; + if (isMobile) { + setOpenMobile(original.openMobile); + } else { + setOpen(original.open); + } + originalSidebarStateRef.current = null; + }, [isMobile, setOpen, setOpenMobile]); + + const completeGuide = useCallback(() => { + storeGuideProgress('completed'); + setStepIndex(null); + setTargetRect(null); + setPopoverPosition(null); + restoreSidebarState(); + }, [restoreSidebarState]); + + const measure = useCallback(() => { + if (!activeStepId) return; + const target = getTarget(activeStepId); + if (!target) return; + + const rect = target.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + const nextRect: TargetRect = { + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + width: rect.width, + height: rect.height, + }; + setTargetRect(nextRect); + + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const popoverHeight = popoverRef.current?.offsetHeight ?? 210; + const rightSpace = viewportWidth - nextRect.right - VIEWPORT_GAP * 2; + const leftSpace = nextRect.left - VIEWPORT_GAP * 2; + + let width = Math.min(MAX_POPOVER_WIDTH, viewportWidth - VIEWPORT_GAP * 2); + let left = VIEWPORT_GAP; + let top = nextRect.bottom + VIEWPORT_GAP; + + if (rightSpace >= MIN_POPOVER_WIDTH) { + width = Math.min(MAX_POPOVER_WIDTH, rightSpace); + left = nextRect.right + VIEWPORT_GAP; + top = clamp( + nextRect.top, + VIEWPORT_GAP, + viewportHeight - popoverHeight - VIEWPORT_GAP, + ); + } else if (leftSpace >= MIN_POPOVER_WIDTH) { + width = Math.min(MAX_POPOVER_WIDTH, leftSpace); + left = nextRect.left - VIEWPORT_GAP - width; + top = clamp( + nextRect.top, + VIEWPORT_GAP, + viewportHeight - popoverHeight - VIEWPORT_GAP, + ); + } else { + top = + nextRect.bottom + VIEWPORT_GAP + popoverHeight <= viewportHeight + ? nextRect.bottom + VIEWPORT_GAP + : nextRect.top - popoverHeight - VIEWPORT_GAP; + top = clamp( + top, + VIEWPORT_GAP, + viewportHeight - popoverHeight - VIEWPORT_GAP, + ); + } + + setPopoverPosition({ left, top, width }); + }, [activeStepId]); + + useEffect(() => { + if (stepIndex === null) return; + + if (!originalSidebarStateRef.current) { + originalSidebarStateRef.current = { open, openMobile }; + } + + if (isMobile) { + setOpenMobile(true); + } else { + setOpen(true); + } + }, [isMobile, open, openMobile, setOpen, setOpenMobile, stepIndex]); + + useEffect(() => { + if (stepIndex === null) return; + + const frame = window.requestAnimationFrame(() => { + const availableIndex = findAvailableStep(stepIndex); + if (availableIndex === null) { + completeGuide(); + return; + } + if (availableIndex !== stepIndex) { + storeGuideProgress(String(availableIndex)); + setStepIndex(availableIndex); + } + }); + + return () => window.cancelAnimationFrame(frame); + }, [completeGuide, stepIndex]); + + useEffect(() => { + if (!activeStepId) return; + const target = getTarget(activeStepId); + if (!target) return; + + target.scrollIntoView({ block: 'nearest' }); + + const delayedMeasure = window.setTimeout(measure, 260); + const resizeObserver = new ResizeObserver(measure); + resizeObserver.observe(target); + window.addEventListener('resize', measure); + window.addEventListener('scroll', measure, true); + + return () => { + window.clearTimeout(delayedMeasure); + resizeObserver.disconnect(); + window.removeEventListener('resize', measure); + window.removeEventListener('scroll', measure, true); + }; + }, [activeStepId, measure]); + + useEffect(() => { + if (!isPopoverReady) return; + measure(); + confirmButtonRef.current?.focus(); + }, [activeStepId, isPopoverReady, measure]); + + useEffect(() => { + if (stepIndex === null) return; + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + const blockKeyboardNavigation = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + } + if (event.key === 'Tab') { + event.preventDefault(); + confirmButtonRef.current?.focus(); + } + }; + window.addEventListener('keydown', blockKeyboardNavigation, true); + + return () => { + document.body.style.overflow = previousOverflow; + window.removeEventListener('keydown', blockKeyboardNavigation, true); + }; + }, [stepIndex]); + + function handleConfirm() { + if (stepIndex === null) return; + const nextIndex = findAvailableStep(stepIndex + 1); + if (nextIndex === null) { + completeGuide(); + return; + } + + storeGuideProgress(String(nextIndex)); + setTargetRect(null); + setPopoverPosition(null); + setStepIndex(nextIndex); + } + + if ( + !activeStepId || + !targetRect || + !popoverPosition || + typeof document === 'undefined' + ) { + return null; + } + + const isLastVisibleStep = + findAvailableStep((stepIndex ?? GUIDE_STEP_IDS.length - 1) + 1) === null; + const titleId = `sidebar-guide-${activeStepId}-title`; + + return createPortal( +
+