import { ArrowTopRightOnSquareIcon, ChevronRightIcon, ExclamationTriangleIcon, PencilSquareIcon, } from "@heroicons/react/24/outline"; import { Link, useFetcher, useNavigation } from "@remix-run/react"; import { LayoutGroup, motion } from "framer-motion"; import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; import simplur from "simplur"; import { AIChatIcon } from "~/assets/icons/AIChatIcon"; import { AIPenIcon } from "~/assets/icons/AIPenIcon"; import { ArrowLeftRightIcon } from "~/assets/icons/ArrowLeftRightIcon"; import { ArrowRightSquareIcon } from "~/assets/icons/ArrowRightSquareIcon"; import { AvatarCircleIcon } from "~/assets/icons/AvatarCircleIcon"; import { BatchesIcon } from "~/assets/icons/BatchesIcon"; import { BellIcon } from "~/assets/icons/BellIcon"; import { Box3DIcon } from "~/assets/icons/Box3DIcon"; import { BugIcon } from "~/assets/icons/BugIcon"; import { ChartBarIcon } from "~/assets/icons/ChartBarIcon"; import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon"; import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon"; import { DeploymentsIcon } from "~/assets/icons/DeploymentsIcon"; import { DialIcon } from "~/assets/icons/DialIcon"; import { DropdownIcon } from "~/assets/icons/DropdownIcon"; import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; import { FolderClosedIcon } from "~/assets/icons/FolderClosedIcon"; import { FolderOpenIcon } from "~/assets/icons/FolderOpenIcon"; import { GlobeLinesIcon } from "~/assets/icons/GlobeLinesIcon"; import { HomeIcon } from "~/assets/icons/HomeIcon"; import { IDIcon } from "~/assets/icons/IDIcon"; import { IntegrationsIcon } from "~/assets/icons/IntegrationsIcon"; import { KeyIcon } from "~/assets/icons/KeyIcon"; import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; import { LogsIcon } from "~/assets/icons/LogsIcon"; import { PlusIcon } from "~/assets/icons/PlusIcon"; import { QueuesIcon } from "~/assets/icons/QueuesIcon"; import { RunsIcon } from "~/assets/icons/RunsIcon"; import { SlidersIcon } from "~/assets/icons/SlidersIcon"; import { TasksIcon } from "~/assets/icons/TasksIcon"; import { UsageIcon } from "~/assets/icons/UsageIcon"; import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; import { Avatar } from "~/components/primitives/Avatar"; import { type MatchedEnvironment } from "~/hooks/useEnvironment"; import { useFeatureFlags } from "~/hooks/useFeatureFlags"; import { useFeatures } from "~/hooks/useFeatures"; import { type MatchedOrganization } from "~/hooks/useOrganizations"; import { type MatchedProject } from "~/hooks/useProject"; import { useShortcutKeys } from "~/hooks/useShortcutKeys"; import { useHasAdminAccess } from "~/hooks/useUser"; import { type UserWithDashboardPreferences } from "~/models/user.server"; import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route"; import { type FeedbackType } from "~/routes/resources.feedback"; import { IncidentStatusPanel, useIncidentStatus } from "~/routes/resources.incidents"; import { cn } from "~/utils/cn"; import { accountPath, adminPath, branchesPath, concurrencyPath, limitsPath, logoutPath, newOrganizationPath, newProjectPath, organizationPath, organizationSettingsPath, organizationTeamPath, queryPath, regionsPath, v3ApiKeysPath, v3BatchesPath, v3BillingPath, v3BulkActionsPath, v3DashboardsLandingPath, v3DeploymentsPath, v3EnvironmentPath, v3EnvironmentVariablesPath, v3ErrorsPath, v3LogsPath, v3ModelsPath, v3ProjectAlertsPath, v3ProjectPath, v3ProjectSettingsGeneralPath, v3ProjectSettingsIntegrationsPath, v3PromptsPath, v3QueuesPath, v3RunsPath, v3SessionsPath, v3UsagePath, v3WaitpointTokensPath, } from "~/utils/pathBuilder"; import { AskAI } from "../AskAI"; import { FreePlanUsage } from "../billing/FreePlanUsage"; import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence"; import { AlphaBadge, NewBadge } from "../FeatureBadges"; import { ImpersonationBanner } from "../ImpersonationBanner"; import { Button, ButtonContent, LinkButton } from "../primitives/Buttons"; import { Dialog, DialogTrigger } from "../primitives/Dialog"; import { Paragraph } from "../primitives/Paragraph"; import { Popover, PopoverContent, PopoverMenuItem, PopoverTrigger } from "../primitives/Popover"; import { ShortcutKey } from "../primitives/ShortcutKey"; import { TextLink } from "../primitives/TextLink"; import { SimpleTooltip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "../primitives/Tooltip"; import { ShortcutsAutoOpen } from "../Shortcuts"; import { CreateDashboardButton } from "./DashboardDialogs"; import { DashboardList } from "./DashboardList"; import { EnvironmentSelector } from "./EnvironmentSelector"; import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { NotificationPanel } from "./NotificationPanel"; import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; import { SideMenuSection } from "./SideMenuSection"; import { type SideMenuSectionId } from "./sideMenuTypes"; /** Get the collapsed state for a specific side menu section from user preferences */ function getSectionCollapsed( sideMenu: { collapsedSections?: Record } | undefined, sectionId: SideMenuSectionId ): boolean { return sideMenu?.collapsedSections?.[sectionId] ?? false; } type SideMenuUser = Pick< UserWithDashboardPreferences, "email" | "admin" | "dashboardPreferences" > & { isImpersonating: boolean; }; export type SideMenuProject = Pick< MatchedProject, "id" | "name" | "slug" | "version" | "environments" | "engine" | "createdAt" >; export type SideMenuEnvironment = MatchedEnvironment; type SideMenuProps = { user: SideMenuUser; project: SideMenuProject; environment: SideMenuEnvironment; organization: MatchedOrganization; organizations: MatchedOrganization[]; button?: ReactNode; defaultValue?: FeedbackType; }; export function SideMenu({ user, project, environment, organization, organizations, }: SideMenuProps) { const borderRef = useRef(null); const [showHeaderDivider, setShowHeaderDivider] = useState(false); const [isCollapsed, setIsCollapsed] = useState( user.dashboardPreferences.sideMenu?.isCollapsed ?? false ); const preferencesFetcher = useFetcher(); const pendingPreferencesRef = useRef<{ isCollapsed?: boolean; sectionId?: SideMenuSectionId; sectionCollapsed?: boolean; }>({}); const debounceTimeoutRef = useRef(null); const currentPlan = useCurrentPlan(); const { isConnected } = useDevPresence(); const isFreeUser = currentPlan?.v3Subscription?.isPaying === false; const isAdmin = useHasAdminAccess(); const { isManagedCloud } = useFeatures(); const featureFlags = useFeatureFlags(); const incidentStatus = useIncidentStatus(); const isV3Project = project.engine === "V1"; const persistSideMenuPreferences = useCallback( (data: { isCollapsed?: boolean; sectionId?: SideMenuSectionId; sectionCollapsed?: boolean; }) => { if (user.isImpersonating) return; // Merge with any pending changes pendingPreferencesRef.current = { ...pendingPreferencesRef.current, ...data, }; // Clear existing timeout if (debounceTimeoutRef.current) { clearTimeout(debounceTimeoutRef.current); } // Debounce the actual submission by 500ms debounceTimeoutRef.current = setTimeout(() => { const pending = pendingPreferencesRef.current; const formData = new FormData(); if (pending.isCollapsed !== undefined) { formData.append("isCollapsed", String(pending.isCollapsed)); } if (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined) { formData.append("sectionId", pending.sectionId); formData.append("sectionCollapsed", String(pending.sectionCollapsed)); } preferencesFetcher.submit(formData, { method: "POST", action: "/resources/preferences/sidemenu", }); pendingPreferencesRef.current = {}; }, 500); }, [user.isImpersonating, preferencesFetcher] ); // Flush pending preferences on unmount to avoid losing the last toggle useEffect(() => { return () => { if (debounceTimeoutRef.current) { clearTimeout(debounceTimeoutRef.current); } if (user.isImpersonating) return; const pending = pendingPreferencesRef.current; const hasPendingChanges = pending.isCollapsed !== undefined || (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined); if (hasPendingChanges) { const formData = new FormData(); if (pending.isCollapsed !== undefined) { formData.append("isCollapsed", String(pending.isCollapsed)); } if (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined) { formData.append("sectionId", pending.sectionId); formData.append("sectionCollapsed", String(pending.sectionCollapsed)); } preferencesFetcher.submit(formData, { method: "POST", action: "/resources/preferences/sidemenu", }); pendingPreferencesRef.current = {}; } }; }, [preferencesFetcher, user.isImpersonating]); const handleToggleCollapsed = () => { const newIsCollapsed = !isCollapsed; setIsCollapsed(newIsCollapsed); persistSideMenuPreferences({ isCollapsed: newIsCollapsed }); }; /** Generic handler for any collapsible section - just pass the section ID */ const handleSectionToggle = useCallback( (sectionId: SideMenuSectionId) => (collapsed: boolean) => { persistSideMenuPreferences({ sectionId, sectionCollapsed: collapsed }); }, [persistSideMenuPreferences] ); useShortcutKeys({ shortcut: { modifiers: ["mod"], key: "b", enabledOnInputElements: true }, action: handleToggleCollapsed, }); useEffect(() => { const handleScroll = () => { if (borderRef.current) { const shouldShowHeaderDivider = borderRef.current.scrollTop > 1; if (showHeaderDivider !== shouldShowHeaderDivider) { setShowHeaderDivider(shouldShowHeaderDivider); } } }; borderRef.current?.addEventListener("scroll", handleScroll); return () => borderRef.current?.removeEventListener("scroll", handleScroll); }, [showHeaderDivider]); return (
{isAdmin && !user.isImpersonating ? ( Admin dashboard ) : isAdmin && user.isImpersonating ? ( ) : null}
{environment.type === "DEVELOPMENT" && project.engine === "V2" && (
{isConnected === undefined ? "Checking connection…" : isConnected ? "Your dev server is connected" : "Your dev server is not connected"}
)}
} isCollapsed={isCollapsed} />
{(user.admin || user.isImpersonating || featureFlags.hasAiAccess) && ( } isCollapsed={isCollapsed} /> {(user.admin || user.isImpersonating || featureFlags.hasAiAccess) && ( } isCollapsed={isCollapsed} /> )} )} {(user.admin || user.isImpersonating || featureFlags.hasQueryAccess) && ( {(user.admin || user.isImpersonating || featureFlags.hasLogsPageAccess) && ( } isCollapsed={isCollapsed} /> )} } /> )} {isManagedCloud && ( )}
{isFreeUser && ( )}
); } function V3DeprecationPanel({ isCollapsed, isV3, projectCreatedAt, hasIncident, isManagedCloud, }: { isCollapsed: boolean; isV3: boolean; projectCreatedAt: Date; hasIncident: boolean; isManagedCloud: boolean; }) { // Only show for projects created before v4 was released const V4_RELEASE_DATE = new Date("2025-09-01"); const isLikelyV3 = isV3 && new Date(projectCreatedAt) < V4_RELEASE_DATE; if (!isManagedCloud || !isLikelyV3 || hasIncident) { return null; } return (
} content="V3 deprecation warning" side="right" sideOffset={8} disableHoverableContent asChild />
); } function V3DeprecationContent() { return (
V3 deprecation warning
This is a v3 project. V3 deploys will stop working on 1 April 2026. Full shutdown is 1 July 2026 where all v3 runs will stop executing. Migrate to v4 to avoid downtime. View migration guide
); } function ProjectSelector({ project, organization, organizations, user, isCollapsed = false, }: { project: SideMenuProject; organization: MatchedOrganization; organizations: MatchedOrganization[]; user: SideMenuUser; isCollapsed?: boolean; }) { const currentPlan = useCurrentPlan(); const [isOrgMenuOpen, setOrgMenuOpen] = useState(false); const navigation = useNavigation(); const { isManagedCloud } = useFeatures(); let plan: string | undefined = undefined; if (currentPlan?.v3Subscription?.isPaying === false) { plan = "Free"; } else if (currentPlan?.v3Subscription?.isPaying === true) { plan = currentPlan.v3Subscription.plan?.title; } useEffect(() => { setOrgMenuOpen(false); }, [navigation.location?.pathname]); return ( setOrgMenuOpen(open)} open={isOrgMenuOpen}> {project.name ?? "Select a project"} } content={`${organization.title} / ${project.name ?? "Select a project"}`} side="right" sideOffset={8} hidden={!isCollapsed} buttonClassName="h-8!" asChild disableHoverableContent />
{organization.title}
{plan && ( {plan} plan )} {simplur`${organization.membersCount} member[|s]`}
Settings {isManagedCloud && ( Usage )}
{organization.projects.map((p) => { const isSelected = p.id === project.id; return ( {p.name}
} isSelected={isSelected} icon={isSelected ? FolderOpenIcon : FolderClosedIcon} leadingIconClassName="text-indigo-500" /> ); })}
{organizations.length > 1 ? ( ) : ( )}
); } function SwitchOrganizations({ organizations, organization, }: { organizations: MatchedOrganization[]; organization: MatchedOrganization; }) { const navigation = useNavigation(); const [isMenuOpen, setMenuOpen] = useState(false); const timeoutRef = useRef(null); // Clear timeout on unmount useEffect(() => { return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, []); useEffect(() => { setMenuOpen(false); }, [navigation.location?.pathname]); const handleMouseEnter = () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } setMenuOpen(true); }; const handleMouseLeave = () => { // Small delay before closing to allow moving to the content timeoutRef.current = setTimeout(() => { setMenuOpen(false); }, 150); }; return ( setMenuOpen(open)} open={isMenuOpen}>
Switch organization
{organizations.map((org) => ( } leadingIconClassName="text-text-dimmed" isSelected={org.id === organization.id} /> ))}
); } function SelectorDivider() { return ( ); } /** Helper component that fades out but preserves width (collapses to 0 width) */ function CollapsibleElement({ isCollapsed, children, className, }: { isCollapsed: boolean; children: ReactNode; className?: string; }) { return (
{children}
); } /** Helper component that fades out and collapses height completely */ function CollapsibleHeight({ isCollapsed, children, className, }: { isCollapsed: boolean; children: ReactNode; className?: string; }) { return (
{children}
); } function HelpAndAI({ isCollapsed, organizationId, projectId, }: { isCollapsed: boolean; organizationId: string; projectId: string; }) { return (
); } function AnimatedChevron({ isHovering, isCollapsed, }: { isHovering: boolean; isCollapsed: boolean; }) { // When hovering and expanded: left chevron (pointing left to collapse) // When hovering and collapsed: right chevron (pointing right to expand) // When not hovering: straight vertical line const getRotation = () => { if (!isHovering) return { top: 0, bottom: 0 }; if (isCollapsed) { // Right chevron return { top: -17, bottom: 17 }; } else { // Left chevron return { top: 17, bottom: -17 }; } }; const { top, bottom } = getRotation(); // Calculate horizontal offset to keep chevron centered when rotated // Left chevron: translate left (-1.5px) // Right chevron: translate right (+1.5px) const getTranslateX = () => { if (!isHovering) return 0; return isCollapsed ? 1.5 : -1.5; }; return ( {/* Top segment */} {/* Bottom segment */} ); } function CollapseToggle({ isCollapsed, onToggle }: { isCollapsed: boolean; onToggle: () => void }) { const [isHovering, setIsHovering] = useState(false); return (
{/* Vertical line to mask the side menu border */}
{isCollapsed ? "Expand" : "Collapse"}
); }