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 { AgentContextualSidebar } from "./AgentContextualSidebar"; import { RoutineContextualSidebar } from "./RoutineContextualSidebar"; import { SkillsContextualSidebar } from "./SkillsContextualSidebar"; 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 { ContextualSidebarFrame } from "./ContextualSidebarFrame"; 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 { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled"; import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory"; import { healthApi } from "../api/health"; import { instanceSettingsApi } from "../api/instanceSettings"; import { resolveArchivedCompanyBounce, shouldSyncCompanySelectionFromRoute } from "../lib/company-selection"; import { useOptionalToastActions } from "../context/ToastContext"; 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 { classifyShellRoute, getCompanyPathSegments, rememberContextualSidebarOrigin, type ContextualSidebarSurface, } from "../lib/shell-navigation"; 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; } const RESERVED_APP_SUBPATHS = new Set([ "browse", "connections", "connect", "vercel-connect", "review", "attention", "gateways", "advanced", "app", ]); export function Layout() { const { sidebarOpen, setSidebarOpen, toggleSidebar, collapsed, peeking, setPeeking, isMobile, setForceCollapsed, } = useSidebar(); const { openNewIssue, openOnboarding } = useDialogActions(); const { togglePanelVisible } = usePanel(); // Optional: Layout also renders in harnesses without a ToastProvider. const pushToast = useOptionalToastActions()?.pushToast ?? null; const { companies, loading: companiesLoading, selectedCompany, selectedCompanyId, selectionSource, setSelectedCompanyId, } = useCompany(); const { companyPrefix, pluginRoutePath: matchedPluginRoutePath, agentId, routineId, } = useParams<{ companyPrefix: string; pluginRoutePath?: string; agentId?: string; routineId?: string; }>(); const navigate = useNavigate(); const location = useLocation(); const navigationType = useNavigationType(); const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled(); const shellRoute = classifyShellRoute(location.pathname, companyPrefix); const isCompanySettingsRoute = shellRoute.builtInContextualSurface === "settings"; const companyPathSegments = shellRoute.companySegments; const isTaskDetailRoute = shellRoute.isTaskDetail; const useStreamlinedTaskDetailShell = streamlinedUiEnabled && isTaskDetailRoute; 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; 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], ); // Most contextual routes replace the global navigation inside the same // sidebar shell. Skills, Apps, Agent details, and Routine details are the // exceptions in Streamlined UI: their local navigation is a second rail // beside the persistent company nav. const sharedSecondarySidebar = isCompanySettingsRoute ? ( ) : !streamlinedUiEnabled && shellRoute.builtInContextualSurface === "skills" ? ( ) : appDetailConnectionId ? ( ) : appDetailApplicationId ? ( ) : isAppsRoute || isToolsRoute ? ( ) : routeSidebarSlot ? ( streamlinedUiEnabled ? ( ) : ( ) ) : null; const secondarySidebar = streamlinedUiEnabled && shellRoute.builtInContextualSurface === "agent" && agentId ? ( ) : streamlinedUiEnabled && shellRoute.builtInContextualSurface === "routine" && routineId ? ( ) : streamlinedUiEnabled && shellRoute.builtInContextualSurface === "skills" ? ( ) : sharedSecondarySidebar; const hasSecondarySidebar = secondarySidebar != null; const keepsPrimarySidebar = streamlinedUiEnabled && hasSecondarySidebar && ( shellRoute.builtInContextualSurface === "settings" || shellRoute.builtInContextualSurface === "skills" || shellRoute.builtInContextualSurface === "agent" || shellRoute.builtInContextualSurface === "routine" || isAppsRoute || isToolsRoute ); const replacesPrimarySidebar = streamlinedUiEnabled && hasSecondarySidebar && !keepsPrimarySidebar; const showsAdjacentSecondarySidebar = hasSecondarySidebar && (!streamlinedUiEnabled || keepsPrimarySidebar); const contextualSurface: ContextualSidebarSurface | null = !streamlinedUiEnabled || !hasSecondarySidebar ? null : routeSidebarSlot && !shellRoute.builtInContextualSurface ? `plugin:${routeSidebarSlot.id}` : shellRoute.builtInContextualSurface; const previousShellRoute = useRef<{ pathname: string; surface: ContextualSidebarSurface | null; } | null>(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; useLayoutEffect(() => { setForceCollapsed(!streamlinedUiEnabled && hasSecondarySidebar); return () => setForceCollapsed(false); }, [hasSecondarySidebar, setForceCollapsed, streamlinedUiEnabled]); useEffect(() => { const previous = previousShellRoute.current; if ( contextualSurface && companyPrefix && previous && previous.surface !== contextualSurface && previous.pathname !== location.pathname ) { rememberContextualSidebarOrigin({ surface: contextualSurface, companyPrefix, previousPathname: previous.pathname, }); } previousShellRoute.current = { pathname: location.pathname, surface: contextualSurface }; }, [companyPrefix, contextualSurface, location.pathname]); 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; } // Stale state (remembered paths, history, bookmarks, restored tabs) // deposits users into archived companies long after archiving; a cold // arrival bounces to an active company instead of dwelling there. // Deliberate visits (the company is already the selection) stay put. const bounce = resolveArchivedCompanyBounce({ matchedCompany, selectedCompanyId, companies, }); if (bounce) { pushToast?.({ title: `${matchedCompany.name} is archived`, body: `Switched to ${bounce.name}.`, tone: "info", dedupeKey: `archived-company-bounce:${matchedCompany.id}`, }); setSelectedCompanyId(bounce.id, { source: "route_sync" }); navigate(`/${bounce.issuePrefix}/dashboard`, { 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, pushToast, selectionSource, selectedCompanyId, setSelectedCompanyId, ]); const togglePanel = togglePanelVisible; 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, 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 && }
); }