import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react' import { Link, useLocation } from 'react-router-dom' import type { LucideIcon } from 'lucide-react' import { LayoutGrid, Clock, AlertTriangle, GitBranch, Code2, Wand2, ListChecks, Download, BarChart3, Settings, Pin, PinOff, History, FileText, Network, Ticket, } from 'lucide-react' import { cn } from '@/lib/utils' import { useUserPreferencesStore } from '@/store/userPreferencesStore' import { sidebarApi } from '@/api' import type { SidebarStatsResponse } from '@/api/sidebar' import { prefetchForRoute } from '@/lib/routePrefetch' /* ── Types ──────────────────────────────────────────── */ interface NavSubItem { href: string label: string count?: number } interface NavEntry { href: string icon: LucideIcon label: string shortLabel: string badge?: number matchPaths?: string[] children?: NavSubItem[] } interface NavSection { title: string items: NavEntry[] } /* ── Sidebar component ──────────────────────────────── */ export function Sidebar() { const location = useLocation() const sidebarPinned = useUserPreferencesStore(s => s.sidebarPinned) const toggleSidebarPinned = useUserPreferencesStore(s => s.toggleSidebarPinned) const [stats, setStats] = useState(null) // Phase 6: pending-drafts badge on the Scripts nav. Fetched independently // of the main stats endpoint so backend changes aren't coupled — worst // case the badge doesn't show, rest of the sidebar still renders. const [pendingDraftCount, setPendingDraftCount] = useState(0) const [flyoutIndex, setFlyoutIndex] = useState(null) const flyoutTimeout = useRef | null>(null) const sidebarRef = useRef(null) const statsRequestId = useRef(0) /* ── Stats fetching ───────────────────────────────── */ const refreshStats = useCallback(() => { const requestId = ++statsRequestId.current sidebarApi.getStats() .then(data => { if (requestId === statsRequestId.current) setStats(data) }) .catch(() => {}) // Phase 6: pending draft templates — soft-fail, optional import keeps // the sidebar robust if the endpoint is momentarily unavailable. import('@/api/draftTemplates').then(({ draftTemplatesApi }) => { draftTemplatesApi.list(true) .then(drafts => setPendingDraftCount(drafts.length)) .catch(() => {}) }).catch(() => {}) }, []) useEffect(() => { refreshStats() }, [location.pathname, refreshStats]) useEffect(() => { window.addEventListener('session-changed', refreshStats) return () => window.removeEventListener('session-changed', refreshStats) }, [refreshStats]) /* ── Navigation data ──────────────────────────────── */ /* ── Grouped nav: 5 top-level icons (Sentry-style) ── */ const railGroups: NavEntry[] = [ { href: '/', icon: LayoutGrid, label: 'Home', shortLabel: 'Home', matchPaths: ['/'], }, { href: '/sessions', icon: History, label: 'History', shortLabel: 'History', badge: stats?.active_count || undefined, matchPaths: ['/sessions', '/escalations', '/pilot'], children: [ { href: '/sessions', label: 'Session History', count: stats?.active_count || undefined }, { href: '/escalations', label: 'Escalations', count: stats?.escalation_count || undefined }, ], }, { href: '/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets', matchPaths: ['/tickets'], }, { href: '/trees', icon: GitBranch, label: 'Flows', shortLabel: 'Flows', badge: stats?.tree_counts.total || undefined, matchPaths: ['/trees', '/flows', '/my-trees', '/step-library', '/review-queue', '/network-diagrams'], children: [ { href: '/trees', label: 'Flow Library', count: stats?.tree_counts.total || undefined }, { href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined }, { href: '/network-diagrams', label: 'Network Maps' }, { href: '/step-library', label: 'Solutions Library' }, { href: '/review-queue', label: 'Review Queue' }, ], }, { href: '/scripts', icon: FileText, label: 'Scripts', shortLabel: 'Scripts', badge: pendingDraftCount || undefined, matchPaths: ['/scripts', '/script-builder'], children: [ { href: '/scripts', label: 'Script Library', count: pendingDraftCount || undefined }, { href: '/script-builder', label: 'Script Builder' }, ], }, { href: '/analytics', icon: BarChart3, label: 'Insights', shortLabel: 'Data', matchPaths: ['/analytics', '/shares'], children: [ { href: '/analytics', label: 'Analytics' }, { href: '/shares', label: 'Exports' }, ], }, ] /* Pinned mode still uses the detailed section layout */ const sections: NavSection[] = [ { title: 'RESOLVE', items: [ { href: '/', icon: LayoutGrid, label: 'Dashboard', shortLabel: 'Dash' }, { href: '/sessions', icon: Clock, label: 'Session History', shortLabel: 'History', badge: stats?.active_count || undefined, matchPaths: ['/sessions'] }, { href: '/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets', matchPaths: ['/tickets'] }, { href: '/escalations', icon: AlertTriangle, label: 'Escalations', shortLabel: 'Escal', badge: stats?.escalation_count || undefined }, ], }, { title: 'KNOWLEDGE', items: [ { href: '/trees', icon: GitBranch, label: 'Flow Library', shortLabel: 'Flows', badge: stats?.tree_counts.total || undefined, matchPaths: ['/trees', '/flows', '/my-trees'], children: [ { href: '/trees', label: 'Flow Library' }, { href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined }, ], }, { href: '/network-diagrams', icon: Network, label: 'Network Maps', shortLabel: 'NetMap', matchPaths: ['/network-diagrams'] }, { href: '/scripts', icon: Code2, label: 'Scripts', shortLabel: 'Scripts' }, { href: '/script-builder', icon: Wand2, label: 'Script Builder', shortLabel: 'Builder' }, { href: '/review-queue', icon: ListChecks, label: 'Review Queue', shortLabel: 'Review' }, ], }, { title: 'INSIGHTS', items: [ { href: '/analytics', icon: BarChart3, label: 'Analytics', shortLabel: 'Stats' }, { href: '/shares', icon: Download, label: 'Exports', shortLabel: 'Export' }, ], }, ] const footerItems: NavEntry[] = [ { href: '/account', icon: Settings, label: 'Account', shortLabel: 'Acct' }, ] /* ── Active detection ─────────────────────────────── */ const isActive = (item: NavEntry) => { if (item.matchPaths) return item.matchPaths.some(p => p === '/' ? location.pathname === '/' : location.pathname.startsWith(p) ) if (item.href === '/') return location.pathname === '/' return location.pathname.startsWith(item.href) } const isChildActive = (child: NavSubItem) => { const fullPath = location.pathname + location.search return fullPath === child.href || fullPath.startsWith(child.href + '&') } /* ── Flyout management ────────────────────────────── */ const openFlyout = (key: string) => { if (flyoutTimeout.current) clearTimeout(flyoutTimeout.current) setFlyoutIndex(key) } const closeFlyout = () => { flyoutTimeout.current = setTimeout(() => setFlyoutIndex(null), 400) } const keepFlyout = () => { if (flyoutTimeout.current) clearTimeout(flyoutTimeout.current) } /* ── Drawer resize ───────────────────────────────── */ const [drawerWidth, setDrawerWidth] = useState(240) const isResizing = useRef(false) const handleResizeStart = (e: ReactPointerEvent) => { e.preventDefault() isResizing.current = true const startX = e.clientX const startWidth = drawerWidth const onMove = (ev: globalThis.PointerEvent) => { if (!isResizing.current) return const newWidth = Math.max(180, Math.min(400, startWidth + (ev.clientX - startX))) setDrawerWidth(newWidth) } const onUp = () => { isResizing.current = false document.removeEventListener('pointermove', onMove) document.removeEventListener('pointerup', onUp) } document.addEventListener('pointermove', onMove) document.addEventListener('pointerup', onUp) } /* Close flyout on Escape */ useEffect(() => { const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setFlyoutIndex(null) } document.addEventListener('keydown', handleKey) return () => document.removeEventListener('keydown', handleKey) }, []) /* ── Wheel forwarding (when sidebar can't scroll) ── */ const handleWheel = (e: React.WheelEvent) => { const el = e.currentTarget const canScroll = el.scrollHeight > el.clientHeight const atTop = el.scrollTop <= 0 const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1 if (!canScroll || (e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) { const main = document.querySelector('.main-content') as HTMLElement | null if (main) { main.scrollTop += e.deltaY; e.preventDefault() } } } /* ── Render helpers ───────────────────────────────── */ const renderRailItem = (item: NavEntry, key: string) => { const active = isActive(item) const Icon = item.icon const hasChildren = item.children && item.children.length > 0 return (
hasChildren && !sidebarPinned ? openFlyout(key) : undefined} > prefetchForRoute(item.href)} onFocus={() => hasChildren && !sidebarPinned ? openFlyout(key) : undefined} className={cn( 'group relative flex flex-col items-center justify-center rounded-lg px-2 py-3 transition-all duration-150', active ? 'bg-accent-dim text-accent-text' : 'text-text-rail-label hover:text-foreground' )} title={item.label} > {item.badge !== undefined && item.badge > 0 && ( {item.badge > 99 ? '99+' : item.badge} )} {item.shortLabel} {/* Flyout rendered as drawer below */}
) } const renderPinnedItem = (item: NavEntry, key: string) => { const active = isActive(item) const Icon = item.icon const fullPath = location.pathname + location.search const activeChild = item.children?.find(c => fullPath === c.href || fullPath.startsWith(c.href + '&')) const isParentDimmed = !!activeChild && active return (
prefetchForRoute(item.href)} className={cn( 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-[0.8125rem] font-medium transition-all duration-150', active ? isParentDimmed ? 'bg-[rgba(96,165,250,0.05)] text-foreground/70' : 'bg-accent-dim text-foreground' : 'text-muted-foreground hover:bg-input hover:text-foreground' )} > {active && !isParentDimmed && (
)} {item.label} {item.badge !== undefined && item.badge > 0 && ( {item.badge} )} {/* Sub-items for pinned mode */} {item.children && item.children.length > 0 && (
{item.children.map(child => { const childActive = isChildActive(child) return ( {child.label} {child.count !== undefined && ( {child.count} )} ) })}
)}
) } /* ── Find active flyout group for drawer ── */ const activeFlyoutGroup = flyoutIndex && !sidebarPinned ? railGroups.find((_, i) => `rail-${i}` === flyoutIndex) || footerItems.find((_, i) => `footer-${i}` === flyoutIndex) : null /* ── Main render ──────────────────────────────────── */ if (sidebarPinned) { return ( ) } /* Icon Rail (default) — 5 grouped icons, Sentry-style */ return (
{/* Rail */} {/* Drawer panel — fixed position, full height, resizable, overlays main content */} {activeFlyoutGroup && activeFlyoutGroup.children && (
{/* Drawer header */}

{activeFlyoutGroup.label}

{/* Drawer items */}
{activeFlyoutGroup.children.map(child => ( {child.label} {child.count !== undefined && ( {child.count} )} ))}
{/* Resize handle */}
)}
) }