import { useCallback, useEffect, useRef, useState, type UIEvent } from "react"; import { useQuery } from "@tanstack/react-query"; import { BookOpen, Moon, Sun } from "lucide-react"; import { Outlet, useLocation, useNavigate, useParams } from "@/lib/router"; import { CompanyRail } from "./CompanyRail"; import { Sidebar } from "./Sidebar"; import { SidebarNavItem } from "./SidebarNavItem"; import { BreadcrumbBar } from "./BreadcrumbBar"; import { PropertiesPanel } from "./PropertiesPanel"; import { CommandPalette } from "./CommandPalette"; import { NewIssueDialog } from "./NewIssueDialog"; import { NewProjectDialog } from "./NewProjectDialog"; import { NewGoalDialog } from "./NewGoalDialog"; import { NewAgentDialog } from "./NewAgentDialog"; import { ToastViewport } from "./ToastViewport"; import { MobileBottomNav } from "./MobileBottomNav"; import { useDialog } from "../context/DialogContext"; import { usePanel } from "../context/PanelContext"; import { useCompany } from "../context/CompanyContext"; import { useSidebar } from "../context/SidebarContext"; import { useTheme } from "../context/ThemeContext"; import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory"; import { healthApi } from "../api/health"; import { queryKeys } from "../lib/queryKeys"; import { cn } from "../lib/utils"; import { Button } from "@/components/ui/button"; export function Layout() { const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar(); const { openNewIssue, openOnboarding } = useDialog(); const { togglePanelVisible } = usePanel(); const { companies, loading: companiesLoading, selectedCompanyId, setSelectedCompanyId } = useCompany(); const { theme, toggleTheme } = useTheme(); const { companyPrefix } = useParams<{ companyPrefix: string }>(); const navigate = useNavigate(); const location = useLocation(); const onboardingTriggered = useRef(false); const lastMainScrollTop = useRef(0); const [mobileNavVisible, setMobileNavVisible] = useState(true); const nextTheme = theme === "dark" ? "light" : "dark"; const { data: health } = useQuery({ queryKey: queryKeys.health, queryFn: () => healthApi.get(), retry: false, }); useEffect(() => { if (companiesLoading || onboardingTriggered.current) return; if (health?.deploymentMode === "authenticated") return; if (companies.length === 0) { onboardingTriggered.current = true; openOnboarding(); } }, [companies, companiesLoading, openOnboarding, health?.deploymentMode]); useEffect(() => { if (!companyPrefix || companiesLoading || companies.length === 0) return; const requestedPrefix = companyPrefix.toUpperCase(); const matched = companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix); if (!matched) { const fallback = (selectedCompanyId ? companies.find((company) => company.id === selectedCompanyId) : null) ?? companies[0]!; navigate(`/${fallback.issuePrefix}/dashboard`, { replace: true }); return; } if (companyPrefix !== matched.issuePrefix) { const suffix = location.pathname.replace(/^\/[^/]+/, ""); navigate(`/${matched.issuePrefix}${suffix}${location.search}`, { replace: true }); return; } if (selectedCompanyId !== matched.id) { setSelectedCompanyId(matched.id, { source: "route_sync" }); } }, [ companyPrefix, companies, companiesLoading, location.pathname, location.search, navigate, selectedCompanyId, setSelectedCompanyId, ]); const togglePanel = togglePanelVisible; // Cmd+1..9 to switch companies const switchCompany = useCallback( (index: number) => { if (index < companies.length) { setSelectedCompanyId(companies[index]!.id); } }, [companies, setSelectedCompanyId], ); useCompanyPageMemory(); useKeyboardShortcuts({ onNewIssue: () => openNewIssue(), onToggleSidebar: toggleSidebar, onTogglePanel: togglePanel, onSwitchCompany: switchCompany, }); useEffect(() => { if (!isMobile) { setMobileNavVisible(true); return; } lastMainScrollTop.current = 0; setMobileNavVisible(true); }, [isMobile]); // Swipe gesture to open/close sidebar on mobile useEffect(() => { if (!isMobile) return; const EDGE_ZONE = 30; // px from left edge to start open-swipe const MIN_DISTANCE = 50; // minimum horizontal swipe distance const MAX_VERTICAL = 75; // max vertical drift before we ignore let startX = 0; let startY = 0; const onTouchStart = (e: TouchEvent) => { const t = e.touches[0]!; startX = t.clientX; startY = t.clientY; }; const onTouchEnd = (e: TouchEvent) => { const t = e.changedTouches[0]!; const dx = t.clientX - startX; const dy = Math.abs(t.clientY - startY); if (dy > MAX_VERTICAL) return; // vertical scroll, ignore // Swipe right from left edge → open if (!sidebarOpen && startX < EDGE_ZONE && dx > MIN_DISTANCE) { setSidebarOpen(true); return; } // Swipe left when open → close if (sidebarOpen && dx < -MIN_DISTANCE) { setSidebarOpen(false); } }; document.addEventListener("touchstart", onTouchStart, { passive: true }); document.addEventListener("touchend", onTouchEnd, { passive: true }); return () => { document.removeEventListener("touchstart", onTouchStart); document.removeEventListener("touchend", onTouchEnd); }; }, [isMobile, sidebarOpen, setSidebarOpen]); const handleMainScroll = useCallback( (event: UIEvent) => { if (!isMobile) return; const currentTop = event.currentTarget.scrollTop; const delta = currentTop - lastMainScrollTop.current; if (currentTop <= 24) { setMobileNavVisible(true); } else if (delta > 8) { setMobileNavVisible(false); } else if (delta < -8) { setMobileNavVisible(true); } lastMainScrollTop.current = currentTop; }, [isMobile], ); return (
Skip to Main Content {/* Mobile backdrop */} {isMobile && sidebarOpen && (
) : (
)} {/* Main content */}
{isMobile && } ); }