import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { BookOpen, Moon, Settings, Sun } from "lucide-react"; import { Link, Outlet, useLocation, useNavigate, useParams } from "@/lib/router"; import { CompanyRail } from "./CompanyRail"; import { Sidebar } from "./Sidebar"; import { InstanceSidebar } from "./InstanceSidebar"; 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 { WorktreeBanner } from "./WorktreeBanner"; 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 { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection"; import { DEFAULT_INSTANCE_SETTINGS_PATH, normalizeRememberedInstanceSettingsPath, } from "../lib/instance-settings"; import { queryKeys } from "../lib/queryKeys"; import { cn } from "../lib/utils"; import { NotFoundPage } from "../pages/NotFound"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath"; function readRememberedInstanceSettingsPath(): string { if (typeof window === "undefined") return DEFAULT_INSTANCE_SETTINGS_PATH; try { return normalizeRememberedInstanceSettingsPath(window.localStorage.getItem(INSTANCE_SETTINGS_MEMORY_KEY)); } catch { return DEFAULT_INSTANCE_SETTINGS_PATH; } } export function Layout() { const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar(); const { openNewIssue, openOnboarding } = useDialog(); const { togglePanelVisible } = usePanel(); const { companies, loading: companiesLoading, selectedCompany, selectedCompanyId, selectionSource, setSelectedCompanyId, } = useCompany(); const { theme, toggleTheme } = useTheme(); const { companyPrefix } = useParams<{ companyPrefix: string }>(); const navigate = useNavigate(); const location = useLocation(); const isInstanceSettingsRoute = location.pathname.startsWith("/instance/"); const onboardingTriggered = useRef(false); const lastMainScrollTop = useRef(0); const [mobileNavVisible, setMobileNavVisible] = useState(true); const [instanceSettingsTarget, setInstanceSettingsTarget] = useState(() => readRememberedInstanceSettingsPath()); const nextTheme = theme === "dark" ? "light" : "dark"; const matchedCompany = useMemo(() => { if (!companyPrefix) return null; const requestedPrefix = companyPrefix.toUpperCase(); return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix) ?? null; }, [companies, companyPrefix]); const hasUnknownCompanyPrefix = Boolean(companyPrefix) && !companiesLoading && companies.length > 0 && !matchedCompany; 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; if (!matchedCompany) { const fallback = (selectedCompanyId ? companies.find((company) => company.id === selectedCompanyId) : null) ?? companies[0] ?? null; if (fallback && selectedCompanyId !== fallback.id) { setSelectedCompanyId(fallback.id, { source: "route_sync" }); } return; } if (companyPrefix !== matchedCompany.issuePrefix) { const suffix = location.pathname.replace(/^\/[^/]+/, ""); navigate(`/${matchedCompany.issuePrefix}${suffix}${location.search}`, { replace: true }); return; } if ( shouldSyncCompanySelectionFromRoute({ selectionSource, selectedCompanyId, routeCompanyId: matchedCompany.id, }) ) { setSelectedCompanyId(matchedCompany.id, { source: "route_sync" }); } }, [ companyPrefix, companies, companiesLoading, matchedCompany, location.pathname, location.search, navigate, selectionSource, selectedCompanyId, setSelectedCompanyId, ]); const togglePanel = togglePanelVisible; useCompanyPageMemory(); useKeyboardShortcuts({ onNewIssue: () => openNewIssue(), onToggleSidebar: toggleSidebar, onTogglePanel: togglePanel, }); 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 updateMobileNavVisibility = useCallback((currentTop: number) => { 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; }, []); useEffect(() => { if (!isMobile) { setMobileNavVisible(true); lastMainScrollTop.current = 0; return; } const onScroll = () => { updateMobileNavVisibility(window.scrollY || document.documentElement.scrollTop || 0); }; onScroll(); window.addEventListener("scroll", onScroll, { passive: true }); return () => { window.removeEventListener("scroll", onScroll); }; }, [isMobile, updateMobileNavVisibility]); useEffect(() => { const previousOverflow = document.body.style.overflow; document.body.style.overflow = isMobile ? "visible" : "hidden"; return () => { document.body.style.overflow = previousOverflow; }; }, [isMobile]); useEffect(() => { if (!location.pathname.startsWith("/instance/settings/")) return; const nextPath = normalizeRememberedInstanceSettingsPath( `${location.pathname}${location.search}${location.hash}`, ); setInstanceSettingsTarget(nextPath); try { window.localStorage.setItem(INSTANCE_SETTINGS_MEMORY_KEY, nextPath); } catch { // Ignore storage failures in restricted environments. } }, [location.hash, location.pathname, location.search]); return (
Skip to Main Content
{isMobile && sidebarOpen && (
) : (
{isInstanceSettingsRoute ? : }
Documentation {health?.version && ( v v{health.version} )}
)}
{hasUnknownCompanyPrefix ? ( ) : ( )}
{isMobile && } ); }