import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { useQuery } from "@tanstack/react-query"; import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router"; import { Sidebar } from "./Sidebar"; import { CompanySettingsSidebar } from "./CompanySettingsSidebar"; import { CompanySettingsNav } from "./access/CompanySettingsNav"; import { AppsSidebar } from "./AppsSidebar"; import { AppDetailSidebar } from "./AppConnectionSidebar"; 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 { KeyboardShortcutsCheatsheet } from "./KeyboardShortcutsCheatsheet"; import { ToastViewport } from "./ToastViewport"; import { MobileBottomNav } from "./MobileBottomNav"; import { WorktreeBanner } from "./WorktreeBanner"; import { DevRestartBanner } from "./DevRestartBanner"; import { StandaloneBrowserControls } from "./StandaloneBrowserControls"; import { RouteErrorBoundary } from "./RouteErrorBoundary"; import { SidebarShell } from "./SidebarShell"; import { SecondarySidebar } from "./SecondarySidebar"; import { SidebarAccountMenu } from "./SidebarAccountMenu"; import { useDialogActions } from "../context/DialogContext"; import { GeneralSettingsProvider } from "../context/GeneralSettingsContext"; import { usePanel } from "../context/PanelContext"; import { useCompany } from "../context/CompanyContext"; import { useSidebar } from "../context/SidebarContext"; import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; import { useAppsEnabled } from "../hooks/useAppsEnabled"; import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory"; import { healthApi } from "../api/health"; import { instanceSettingsApi } from "../api/instanceSettings"; import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection"; import { applyMainContentScrollTop, NavigationScrollMemory, resetNavigationScroll, shouldResetScrollOnNavigation, } from "../lib/navigation-scroll"; import { queryKeys } from "../lib/queryKeys"; import { scheduleMainContentFocus } from "../lib/main-content-focus"; import { pinDocumentScrollToZero } from "../lib/pin-document-scroll"; import { cn } from "../lib/utils"; import { NotFoundPage } from "../pages/NotFound"; import { PluginSlotMount, resolveRouteSidebarSlot, usePluginSlots } from "../plugins/slots"; function getCompanyRouteSegment(pathname: string, companyPrefix: string | undefined): string | null { return getCompanyPathSegments(pathname, companyPrefix)[0]?.toLowerCase() ?? null; } function getCompanyPathSegments(pathname: string, companyPrefix: string | undefined): string[] { if (!companyPrefix) return []; const segments = pathname.split("/").filter(Boolean); if (segments.length < 2) return []; if (segments[0]?.toUpperCase() !== companyPrefix.toUpperCase()) return []; return segments.slice(1); } const RESERVED_APP_SUBPATHS = new Set([ "browse", "connections", "connect", "review", "attention", "gateways", "advanced", "app", ]); function isSkillsStoreRoute(pathname: string, companyPrefix: string | undefined) { const segments = pathname.split("/").filter(Boolean); if (segments[0]?.toLowerCase() === "skills") return true; if (!companyPrefix) return false; return ( segments[0]?.toUpperCase() === companyPrefix.toUpperCase() && segments[1]?.toLowerCase() === "skills" ); } export function Layout() { const { sidebarOpen, setSidebarOpen, toggleSidebar, toggleCollapsed, collapsed, peeking, setPeeking, isMobile, setForceCollapsed, } = useSidebar(); const { openNewIssue, openOnboarding } = useDialogActions(); const { togglePanelVisible } = usePanel(); const { companies, loading: companiesLoading, selectedCompany, selectedCompanyId, selectionSource, setSelectedCompanyId, } = useCompany(); const { companyPrefix, pluginRoutePath: matchedPluginRoutePath, } = useParams<{ companyPrefix: string; pluginRoutePath?: string }>(); const navigate = useNavigate(); const location = useLocation(); const navigationType = useNavigationType(); const { enabled: appsEnabled } = useAppsEnabled(); const isCompanySettingsRoute = location.pathname.includes("/company/settings"); const companyPathSegments = getCompanyPathSegments(location.pathname, companyPrefix); const isToolsRoute = companyPathSegments[0]?.toLowerCase() === "tools"; const isAppsRoute = companyPathSegments[0]?.toLowerCase() === "apps"; const appDetailConnectionId = isAppsRoute && companyPathSegments[1] && !RESERVED_APP_SUBPATHS.has(companyPathSegments[1].toLowerCase()) ? companyPathSegments[1] : null; const appDetailApplicationId = isAppsRoute && companyPathSegments[1]?.toLowerCase() === "app" && companyPathSegments[2] ? companyPathSegments[2] : null; // The Skills Store renders its own secondary (category) sidebar, so the main // app nav collapses to its rail throughout the Skills Store section (PAP-10879). const isSkillsRoute = isSkillsStoreRoute(location.pathname, companyPrefix); const onboardingTriggered = useRef(false); const lastMainScrollTop = useRef(0); const previousPathname = useRef(null); const mainContentRef = useRef(null); const scrollMemory = useRef(new NavigationScrollMemory()); const activeScrollKey = useRef(location.key); const [mobileNavVisible, setMobileNavVisible] = useState(true); const [shortcutsOpen, setShortcutsOpen] = useState(false); 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 pluginRoutePath = useMemo( () => matchedPluginRoutePath?.toLowerCase() ?? getCompanyRouteSegment(location.pathname, companyPrefix), [companyPrefix, location.pathname, matchedPluginRoutePath], ); const routeSidebarCompanyId = matchedCompany?.id ?? null; const routeSidebarCompanyPrefix = matchedCompany?.issuePrefix ?? null; const { slots: routeSidebarSlots } = usePluginSlots({ slotTypes: ["page", "routeSidebar"], companyId: routeSidebarCompanyId, enabled: Boolean(routeSidebarCompanyId && pluginRoutePath), }); const routeSidebarSlot = useMemo( () => resolveRouteSidebarSlot(routeSidebarSlots, pluginRoutePath), [pluginRoutePath, routeSidebarSlots], ); const sidebarContext = useMemo( () => ({ companyId: routeSidebarCompanyId, companyPrefix: routeSidebarCompanyPrefix, }), [routeSidebarCompanyId, routeSidebarCompanyPrefix], ); // Takeover routes (company settings, plugin `routeSidebar`) no longer replace // the app ``. Instead the host collapses it to its rail and renders // the contextual sidebar in a second pane (PAP-10695). One resolver drives // both desktop (SecondarySidebar) and mobile (off-canvas drawer). const secondarySidebar = isCompanySettingsRoute ? ( ) : appsEnabled && appDetailConnectionId ? ( ) : appsEnabled && appDetailApplicationId ? ( ) : appsEnabled && (isAppsRoute || isToolsRoute) ? ( ) : routeSidebarSlot ? ( ) : null; const hasSecondarySidebar = secondarySidebar != null; const { data: health } = useQuery({ queryKey: queryKeys.health, queryFn: () => healthApi.get(), retry: false, refetchInterval: (query) => { const data = query.state.data as { devServer?: { enabled?: boolean } } | undefined; return data?.devServer?.enabled ? 2000 : false; }, refetchIntervalInBackground: true, }); const keyboardShortcutsEnabled = useQuery({ queryKey: queryKeys.instance.generalSettings, queryFn: () => instanceSettingsApi.getGeneral(), }).data?.keyboardShortcuts === true; // A secondary sidebar always collapses the app sidebar to its rail (still // peek-able) — a hard invariant that overrides the user pin while the route // is active, but does NOT mutate the persisted preference. Clearing the force // on cleanup restores the user's expanded/collapsed choice when navigating // off the takeover route (PAP-10694). const forceRailCollapsed = hasSecondarySidebar || isSkillsRoute; useLayoutEffect(() => { setForceCollapsed(forceRailCollapsed); return () => setForceCollapsed(false); }, [forceRailCollapsed, setForceCollapsed]); useEffect(() => { if (companiesLoading || onboardingTriggered.current) return; if (health?.deploymentMode === "authenticated") return; // Cloud provisions the single company for a stack, and POST /companies is a // 403 floor there — auto-opening the wizard could only dead-end. if (health?.cloud) return; if (companies.length === 0) { onboardingTriggered.current = true; openOnboarding(); } }, [companies, companiesLoading, openOnboarding, health?.cloud, 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; // Cmd/Ctrl+B: collapse/expand the pinned rail on desktop; on mobile keep // toggling the off-canvas drawer. const toggleCollapse = useCallback(() => { if (isMobile) { toggleSidebar(); } else { toggleCollapsed(); } }, [isMobile, toggleSidebar, toggleCollapsed]); const openSearch = useCallback(() => { document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true, cancelable: true, })); }, []); // Peek (hover flyout) triggers for the collapsed rail. Opening has a tiny // delay so a pointer merely sweeping across the rail doesn't flash it open; // closing is debounced to avoid flicker on the rail→overlay seam. Keyboard // focus opens immediately so tabbing reaches the full nav. Context gates the // effective `peeking` to desktop + collapsed + hover-capable pointers, so // these handlers are inert otherwise. const peekTimer = useRef(null); // Whether the pointer is currently over the peek panel. Used to keep the peek // open across focus changes (e.g. navigation steals focus to
) as long as // the user is still hovering — it should only close when they actually mouse off // (PAP-10676). const pointerInsidePanel = useRef(false); // When the user explicitly collapses while the pointer is still over the panel, // suppress re-peeking until the pointer actually leaves — otherwise the lingering // hover immediately re-expands the rail and the collapse "doesn't take" until the // mouse moves away (PAP-10676). Re-armed on the next genuine pointer-leave. const suppressPeekRef = useRef(false); const clearPeekTimer = useCallback(() => { if (peekTimer.current !== null) { window.clearTimeout(peekTimer.current); peekTimer.current = null; } }, []); const openPeek = useCallback(() => { clearPeekTimer(); peekTimer.current = window.setTimeout(() => setPeeking(true), 50); }, [clearPeekTimer, setPeeking]); const openPeekImmediate = useCallback(() => { clearPeekTimer(); setPeeking(true); }, [clearPeekTimer, setPeeking]); const closePeek = useCallback(() => { clearPeekTimer(); peekTimer.current = window.setTimeout(() => setPeeking(false), 120); }, [clearPeekTimer, setPeeking]); // Tracked even while expanded so that, at the moment of collapse, we know // whether the pointer is over the panel and should suppress the re-peek. const handlePanelPointerEnter = useCallback(() => { pointerInsidePanel.current = true; if (collapsed && !suppressPeekRef.current) openPeek(); }, [collapsed, openPeek]); const handlePanelPointerLeave = useCallback(() => { pointerInsidePanel.current = false; suppressPeekRef.current = false; // pointer left — re-arm peek for the next hover closePeek(); }, [closePeek]); const handlePanelFocus = useCallback(() => { if (suppressPeekRef.current) return; openPeekImmediate(); }, [openPeekImmediate]); // Close on focus leaving the panel only when the pointer isn't hovering it. // Clicking a rail/peek nav item moves focus to
on navigation; if the // mouse is still over the flyout we keep it open until the pointer leaves. const handlePanelBlur = useCallback(() => { if (pointerInsidePanel.current) return; closePeek(); }, [closePeek]); // Tidy up any pending peek timer on unmount. useEffect(() => clearPeekTimer, [clearPeekTimer]); // An explicit collapse must be atomic: cancel any in-flight/active peek, and if // the pointer is still over the panel suppress re-peeking until it leaves, so the // rail doesn't immediately re-expand under the lingering hover (PAP-10676). const wasCollapsed = useRef(collapsed); useEffect(() => { if (collapsed !== wasCollapsed.current) { if (collapsed) { clearPeekTimer(); setPeeking(false); suppressPeekRef.current = pointerInsidePanel.current; } else { suppressPeekRef.current = false; } wasCollapsed.current = collapsed; } }, [collapsed, clearPeekTimer, setPeeking]); // Intentionally do NOT close the peek on navigation: clicking a nav item means // the pointer is still over the flyout, so it should stay open until the user // actually mouses off (handled by onPanelMouseLeave) or blurs out / hits Escape // (PAP-10676). Auto-closing here made the sidebar collapse on every page change. // Escape closes an open peek without trapping the pointer. useEffect(() => { if (!peeking) return; const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { clearPeekTimer(); setPeeking(false); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [peeking, clearPeekTimer, setPeeking]); useCompanyPageMemory(); useKeyboardShortcuts({ enabled: keyboardShortcutsEnabled, onNewIssue: () => openNewIssue(), onSearch: openSearch, onToggleSidebar: toggleSidebar, onToggleCollapse: toggleCollapse, onTogglePanel: togglePanel, onShowShortcuts: () => setShortcutsOpen(true), onGoToInbox: () => navigate("/inbox"), }); 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" : "clip"; return () => { document.body.style.overflow = previousOverflow; }; }, [isMobile]); // `scrollIntoView` walks every ancestor scroll container. On a long thread // the post-submit `scrollIntoView` on the new comment reaches `` and // animates `documentElement.scrollTop` via the browser's internal scroll // algorithm, which bypasses the CSS `overflow` on the root element and // visually shifts the entire shell (sidebar included) off-screen. Pin // both roots to scrollTop=0 on every scroll tick. useEffect(() => { if (isMobile) return; return pinDocumentScrollToZero(); }, [isMobile]); useEffect(() => { if (typeof document === "undefined") return; const mainContent = mainContentRef.current; return scheduleMainContentFocus(mainContent); }, [location.pathname]); // Continuously record the scroll offset of the active history entry so a // later back/forward navigation can restore it (see NavigationScrollMemory). useEffect(() => { const main = mainContentRef.current; if (!main) return; const recordScroll = () => { scrollMemory.current.remember(activeScrollKey.current, main.scrollTop); }; main.addEventListener("scroll", recordScroll, { passive: true }); return () => main.removeEventListener("scroll", recordScroll); }, []); useLayoutEffect(() => { const main = mainContentRef.current; const shouldResetScroll = shouldResetScrollOnNavigation({ previousPathname: previousPathname.current, pathname: location.pathname, navigationType, state: location.state, }); previousPathname.current = location.pathname; const isHistoryPop = navigationType === "POP"; const restoredScrollTop = isHistoryPop ? scrollMemory.current.recall(location.key) : 0; activeScrollKey.current = location.key; if (isHistoryPop) { applyMainContentScrollTop(main, restoredScrollTop); // Cached page content can finish laying out a frame after commit; re-apply // once it has so the restored offset isn't clamped to a shorter interim height. const raf = requestAnimationFrame(() => applyMainContentScrollTop(main, restoredScrollTop)); return () => cancelAnimationFrame(raf); } if (shouldResetScroll) { resetNavigationScroll(main); } }, [location.key, location.pathname, location.state, navigationType]); return (
Skip to Main Content
{isMobile && sidebarOpen && (
{isMobile && }
); }