diff --git a/.server-changes/side-menu-project-and-org-menus.md b/.server-changes/side-menu-project-and-org-menus.md new file mode 100644 index 00000000000..d073d58ccc3 --- /dev/null +++ b/.server-changes/side-menu-project-and-org-menus.md @@ -0,0 +1,41 @@ +--- +area: webapp +type: improvement +--- + +Refresh the side menu and account UI: + +- Add a new "Project" section above the "Environment" section with a popover + that lists the org's projects (folder icon + checkmark for the selected one) + and a "New project" item at the bottom. +- The top-left menu now shows the organization (avatar + org name, no + project/diagonal divider) and its popover is a clean list of org-level items + (Settings, Usage, Billing with plan badge, Billing alerts, Team, Private + connections, Roles, SSO, Vercel integration, Slack integration, Switch + organization), with a separate account menu button (Profile, Personal Access + Tokens, Security, admin/impersonation, Logout) beside it. +- Match the Environment selector popover's item sizing (icons and labels, + including the branch submenu and its footer) to the Project popover so the two + side-menu menus are visually consistent. +- Redesign the account Profile page (/account) into the Security page's + row-and-divider layout: Profile picture, Full name, Email address, and a + "Receive onboarding emails" toggle on equal-height rows, with a primary Update + button. +- Signal impersonation mode with a yellow side-menu border and a matching + "Stop impersonating" accent. +- Move the Settings item to the top of the organization settings side menu. +- Align the organization settings and account side menus' horizontal padding + with the main side menu, and tighten the "Personal Access Tokens" label so it + no longer truncates. +- Restyle the "Shortcuts" and "Contact us…" entries in the Help & Feedback + popover to match the other menu items (icon size/alignment, dimmed text, text + size). +- Make the main side menu resizable: drag its right edge to set a custom width + (remembered per user), with the labels, headers, and padding transitioning in + real time and a snap to open or collapsed when released below the default + width. Clicking the edge toggles the menu and a tooltip explains both + gestures. + +The org loader now exposes whether the RBAC and SSO plugins are installed so the +side menu can gate the Roles and SSO items the same way the settings side menu +does. diff --git a/apps/webapp/app/assets/icons/AvatarCircleIcon.tsx b/apps/webapp/app/assets/icons/AvatarCircleIcon.tsx index 83e67c8468c..30240b51c9a 100644 --- a/apps/webapp/app/assets/icons/AvatarCircleIcon.tsx +++ b/apps/webapp/app/assets/icons/AvatarCircleIcon.tsx @@ -1,4 +1,4 @@ -export function AvatarCircleIcon({ className }: { className?: string }) { +function AvatarCircle({ className, strokeWidth }: { className?: string; strokeWidth: number }) { return ( - - + + ); } + +/** User avatar placeholder with a 2px stroke (the default). */ +export function AvatarCircleIcon({ className }: { className?: string }) { + return ; +} + +/** Thinner 1.5px-stroke variant of {@link AvatarCircleIcon}. */ +export function AvatarCircleIconThin({ className }: { className?: string }) { + return ; +} + +/** Thinnest 1.25px-stroke variant of {@link AvatarCircleIcon}. */ +export function AvatarCircleIconExtraThin({ className }: { className?: string }) { + return ; +} diff --git a/apps/webapp/app/assets/icons/ChainLinkIcon.tsx b/apps/webapp/app/assets/icons/ChainLinkIcon.tsx new file mode 100644 index 00000000000..f2e00245479 --- /dev/null +++ b/apps/webapp/app/assets/icons/ChainLinkIcon.tsx @@ -0,0 +1,27 @@ +export function ChainLinkIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/LeftSideMenuCollapsedIcon.tsx b/apps/webapp/app/assets/icons/LeftSideMenuCollapsedIcon.tsx new file mode 100644 index 00000000000..aa229af92a4 --- /dev/null +++ b/apps/webapp/app/assets/icons/LeftSideMenuCollapsedIcon.tsx @@ -0,0 +1,22 @@ +export function LeftSideMenuCollapsedIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/LeftSideMenuIcon.tsx b/apps/webapp/app/assets/icons/LeftSideMenuIcon.tsx new file mode 100644 index 00000000000..759ff962ba4 --- /dev/null +++ b/apps/webapp/app/assets/icons/LeftSideMenuIcon.tsx @@ -0,0 +1,44 @@ +import { motion } from "framer-motion"; +import { useState } from "react"; + +export function LeftSideMenuIcon({ + className, + hovered: controlledHovered, +}: { + className?: string; + /** Drives the animation when provided (e.g. parent hover); otherwise the icon uses its own hover. */ + hovered?: boolean; +}) { + const [internalHovered, setInternalHovered] = useState(false); + const isControlled = controlledHovered !== undefined; + const hovered = isControlled ? controlledHovered : internalHovered; + + return ( + setInternalHovered(true)} + onMouseLeave={isControlled ? undefined : () => setInternalHovered(false)} + > + + {/* Animate a transform (scaleX), not the SVG `width` attr — framer snaps the first animation + of an idle SVG geometry attribute. Left origin collapses the panel right-to-left. */} + + + ); +} diff --git a/apps/webapp/app/components/AskAI.tsx b/apps/webapp/app/components/AskAI.tsx index d62ffa5b33d..0d32265c251 100644 --- a/apps/webapp/app/components/AskAI.tsx +++ b/apps/webapp/app/components/AskAI.tsx @@ -11,11 +11,12 @@ import { useSearchParams } from "@remix-run/react"; import DOMPurify from "dompurify"; import { motion } from "framer-motion"; import { marked } from "marked"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; import { useTypedRouteLoaderData } from "remix-typedjson"; import { AISparkleIcon } from "~/assets/icons/AISparkleIcon"; import { SparkleListIcon } from "~/assets/icons/SparkleListIcon"; import { useFeatures } from "~/hooks/useFeatures"; +import { useShortcutKeys } from "~/hooks/useShortcutKeys"; import { type loader } from "~/root"; import { Button } from "./primitives/Buttons"; import { Callout } from "./primitives/Callout"; @@ -38,6 +39,104 @@ function useKapaWebsiteId() { return routeMatch?.kapa.websiteId; } +/** Open/close state for the Ask AI dialog, including the `?aiHelp=` deep-link handling. */ +function useAskAIState() { + const [isOpen, setIsOpen] = useState(false); + const [initialQuery, setInitialQuery] = useState(); + const [searchParams, setSearchParams] = useSearchParams(); + + const openAskAI = useCallback((question?: string) => { + if (question) { + setInitialQuery(question); + } else { + setInitialQuery(undefined); + } + setIsOpen(true); + }, []); + + const closeAskAI = useCallback(() => { + setIsOpen(false); + setInitialQuery(undefined); + }, []); + + // Handle URL param functionality + useEffect(() => { + const aiHelp = searchParams.get("aiHelp"); + if (aiHelp) { + // Delay to avoid hCaptcha bot detection + window.setTimeout(() => openAskAI(aiHelp), 1000); + + // Clone instead of mutating in place + const next = new URLSearchParams(searchParams); + next.delete("aiHelp"); + setSearchParams(next); + } + }, [searchParams, openAskAI]); + + return { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI }; +} + +/** + * Hosts Ask AI (Kapa provider, ⌘I shortcut, dialog) for a menu that renders its own trigger. Wrap + * it around the popover, not inside, so the dialog and shortcut survive the popover closing. + * `children` receives the open function, or undefined when Ask AI is unavailable (self-hosted, no + * Kapa website id, or SSR). + */ +export function AskAIRoot({ + children, +}: { + children: (openAskAI: (() => void) | undefined) => ReactNode; +}) { + const { isManagedCloud } = useFeatures(); + const websiteId = useKapaWebsiteId(); + + if (!isManagedCloud || !websiteId) { + return <>{children(undefined)}; + } + + return ( + {children(undefined)}}> + {() => {children}} + + ); +} + +function AskAIRootProvider({ + websiteId, + children, +}: { + websiteId: string; + children: (openAskAI: () => void) => ReactNode; +}) { + const { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI } = useAskAIState(); + + useShortcutKeys({ + shortcut: { modifiers: ["mod"], key: "i", enabledOnInputElements: true }, + action: () => openAskAI(), + }); + + return ( + openAskAI(), + onAnswerGenerationCompleted: () => openAskAI(), + }, + }} + botProtectionMechanism="hcaptcha" + > + {children(() => openAskAI())} + + + ); +} + export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) { const { isManagedCloud } = useFeatures(); const websiteId = useKapaWebsiteId(); @@ -72,37 +171,7 @@ type AskAIProviderProps = { }; function AskAIProvider({ websiteId, isCollapsed = false }: AskAIProviderProps) { - const [isOpen, setIsOpen] = useState(false); - const [initialQuery, setInitialQuery] = useState(); - const [searchParams, setSearchParams] = useSearchParams(); - - const openAskAI = useCallback((question?: string) => { - if (question) { - setInitialQuery(question); - } else { - setInitialQuery(undefined); - } - setIsOpen(true); - }, []); - - const closeAskAI = useCallback(() => { - setIsOpen(false); - setInitialQuery(undefined); - }, []); - - // Handle URL param functionality - useEffect(() => { - const aiHelp = searchParams.get("aiHelp"); - if (aiHelp) { - // Delay to avoid hCaptcha bot detection - window.setTimeout(() => openAskAI(aiHelp), 1000); - - // Clone instead of mutating in place - const next = new URLSearchParams(searchParams); - next.delete("aiHelp"); - setSearchParams(next); - } - }, [searchParams, openAskAI]); + const { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI } = useAskAIState(); return ( { + if (!isAdmin) return; + + const onKeyDown = (event: KeyboardEvent) => { + // Admin escape hatch: Cmd+Option+A (Ctrl+Alt+A on Windows) opens the admin dashboard, or stops + // impersonating. Avoids Escape — Chrome/macOS never delivers a keydown for Escape+modifier (why + // the old Cmd+Esc did nothing). Matched on `event.code`, not `event.key`, because Option makes + // "A" report "å" (so a raw listener, not the `event.key`-based useShortcutKeys hook). + if (event.code !== "KeyA" || !event.altKey || !(event.metaKey || event.ctrlKey)) { + return; + } + event.preventDefault(); + if (isImpersonating) { + submit(null, { action: "/resources/impersonation", method: "delete" }); + } else { + navigate(adminPath()); + } + }; + + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [isAdmin, isImpersonating, navigate, submit]); + + return null; +} diff --git a/apps/webapp/app/components/Shortcuts.tsx b/apps/webapp/app/components/Shortcuts.tsx index 63b5fddf715..87e5d08f371 100644 --- a/apps/webapp/app/components/Shortcuts.tsx +++ b/apps/webapp/app/components/Shortcuts.tsx @@ -1,8 +1,8 @@ import { KeyboardIcon } from "~/assets/icons/KeyboardIcon"; import { useState } from "react"; import { useShortcutKeys } from "~/hooks/useShortcutKeys"; -import { Button } from "./primitives/Buttons"; import { Header3 } from "./primitives/Headers"; +import { SideMenuItemButton } from "./navigation/SideMenuItem"; import { Paragraph } from "./primitives/Paragraph"; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "./primitives/SheetV3"; import { ShortcutKey } from "./primitives/ShortcutKey"; @@ -11,19 +11,12 @@ export function Shortcuts() { return ( - + trailing={} + /> diff --git a/apps/webapp/app/components/UserProfilePhoto.tsx b/apps/webapp/app/components/UserProfilePhoto.tsx index 4676594dbcc..92c435aa109 100644 --- a/apps/webapp/app/components/UserProfilePhoto.tsx +++ b/apps/webapp/app/components/UserProfilePhoto.tsx @@ -1,31 +1,62 @@ -import { UserCircleIcon } from "@heroicons/react/24/solid"; +import { + AvatarCircleIcon, + AvatarCircleIconExtraThin, + AvatarCircleIconThin, +} from "~/assets/icons/AvatarCircleIcon"; import { useOptionalUser } from "~/hooks/useUser"; import { cn } from "~/utils/cn"; -export function UserProfilePhoto({ className }: { className?: string }) { +/** Stroke width (px) of the placeholder avatar icon shown when there is no photo. */ +type AvatarStrokeWidth = 1.25 | 1.5 | 2; + +const PLACEHOLDER_BY_STROKE_WIDTH = { + 1.25: AvatarCircleIconExtraThin, + 1.5: AvatarCircleIconThin, + 2: AvatarCircleIcon, +} as const; + +export function UserProfilePhoto({ + className, + strokeWidth = 2, +}: { + className?: string; + strokeWidth?: AvatarStrokeWidth; +}) { const user = useOptionalUser(); - return ; + return ( + + ); } export function UserAvatar({ avatarUrl, name, className, + strokeWidth = 2, }: { avatarUrl?: string | null; name?: string | null; className?: string; + strokeWidth?: AvatarStrokeWidth; }) { - return avatarUrl ? ( -
- {name -
- ) : ( - - ); + if (avatarUrl) { + return ( +
+ {name +
+ ); + } + + const PlaceholderIcon = PLACEHOLDER_BY_STROKE_WIDTH[strokeWidth]; + return ; } diff --git a/apps/webapp/app/components/environments/EnvironmentLabel.tsx b/apps/webapp/app/components/environments/EnvironmentLabel.tsx index 8143e5e5e7d..3fd5d526fc1 100644 --- a/apps/webapp/app/components/environments/EnvironmentLabel.tsx +++ b/apps/webapp/app/components/environments/EnvironmentLabel.tsx @@ -81,12 +81,15 @@ export function EnvironmentLabel({ tooltipSideOffset = 34, tooltipSide = "right", disableTooltip = false, + truncate = true, }: { environment: Environment; className?: string; tooltipSideOffset?: number; tooltipSide?: "top" | "right" | "bottom" | "left"; disableTooltip?: boolean; + /** When false, the label clips without an ellipsis (side menu fades it in place). Defaults true. */ + truncate?: boolean; }) { const spanRef = useRef(null); const [isTruncated, setIsTruncated] = useState(false); @@ -113,7 +116,12 @@ export function EnvironmentLabel({ const content = ( {text} diff --git a/apps/webapp/app/components/navigation/AccountSideMenu.tsx b/apps/webapp/app/components/navigation/AccountSideMenu.tsx index ba7938adde6..f48de39e2fc 100644 --- a/apps/webapp/app/components/navigation/AccountSideMenu.tsx +++ b/apps/webapp/app/components/navigation/AccountSideMenu.tsx @@ -7,7 +7,6 @@ import { personalAccessTokensPath, rootPath, } from "~/utils/pathBuilder"; -import { AskAI } from "../AskAI"; import { LinkButton } from "../primitives/Buttons"; import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { SideMenuHeader } from "./SideMenuHeader"; @@ -34,7 +33,7 @@ export function AccountSideMenu({ user }: { user: User }) { Back to app -
+
-
); diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index 7e2a3efdb50..b3375681b59 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -35,18 +35,26 @@ import { V4Badge } from "../V4Badge"; import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu"; import { Badge } from "../primitives/Badge"; +// Size this Env popover's items to match the Project popover (SIDE_MENU_POPOVER_ITEM_* in +// SideMenu.tsx). Only at these call sites, so shared EnvironmentLabel/EnvironmentCombo defaults stay. +const ENV_POPOVER_ITEM_ICON = "size-5"; +const ENV_POPOVER_ITEM_LABEL = "text-[0.90625rem] font-medium tracking-[-0.01em]"; + export function EnvironmentSelector({ organization, project, environment, className, isCollapsed = false, + isDragging = false, }: { organization: MatchedOrganization; project: SideMenuProject; environment: SideMenuEnvironment; className?: string; isCollapsed?: boolean; + /** True while the side menu is being drag-resized; keeps the row in its expanded arrangement. */ + isDragging?: boolean; }) { const { isManagedCloud } = useFeatures(); const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -73,42 +81,56 @@ export function EnvironmentSelector({ button={ + {/* + Opacity follows --sm-label-opacity to fade both directions without popping in on + drag-open; the generous max-width cap fades the text in place (not truncated) but + scales to 0 so it never holds width. Unset elsewhere → fully visible. + */} + {/* + Chevron's 16px width follows --sm-label-opacity so an invisible span never holds width + mid-drag and pushes the row's clip edge into the icon. + */} - + } - content={environmentFullTitle(environment)} + content={`${environmentFullTitle(environment)} environment`} side="right" sideOffset={8} + // Tooltip only on the collapsed rail (expanded shows the label; this selector is also reused + // outside the side menu, where a hover tooltip is unwanted). hidden={!isCollapsed} + delayDuration={0} buttonClassName="h-8!" asChild + tabbable disableHoverableContent /> } + title={ + + } isSelected={env.id === environment.id} /> ); @@ -162,8 +190,12 @@ export function EnvironmentSelector({ )} title={
- - Upgrade + + Upgrade
} isSelected={false} @@ -176,8 +208,12 @@ export function EnvironmentSelector({ )} title={
- - Upgrade + + Upgrade
} isSelected={false} @@ -199,10 +235,6 @@ function Branches({ branchEnvironments: SideMenuEnvironment[]; currentEnvironment: SideMenuEnvironment; }) { - const organization = useOrganization(); - const project = useProject(); - const environment = useEnvironment(); - const { urlForEnvironment } = useEnvironmentSwitcher(); const navigation = useNavigation(); const [isMenuOpen, setMenuOpen] = useState(false); const timeoutRef = useRef(null); @@ -234,23 +266,6 @@ function Branches({ }, 150); }; - const activeBranches = branchEnvironments.filter((env) => env.archivedAt === null); - const state = - branchEnvironments.length === 0 - ? "no-branches" - : activeBranches.length === 0 - ? "no-active-branches" - : "has-branches"; - - // Only surface the active environment's archived-branch item in the submenu it - // actually belongs to. Both Development and Preview render this component, so - // without the parent check an archived dev branch would leak into the Preview - // submenu (and vice-versa). - const currentBranchIsArchived = - environment.archivedAt !== null && environment.parentEnvironmentId === parentEnvironment.id; - - const envTextClassName = environmentTextClassName(parentEnvironment); - return ( setMenuOpen(open)} open={isMenuOpen}>
@@ -263,7 +278,11 @@ function Branches({ textAlignLeft fullWidth > - + -
- {currentBranchIsArchived && ( - - - {environment.branchName} - - Archived - - } - icon={ - - } - isSelected={environment.id === currentEnvironment.id} - /> - )} - {state === "has-branches" ? ( + + +
+ + ); +} + +/** + * Inner content of the branches popover (list, empty states, "Manage branches" footer). Shared by + * the `Branches` hover submenu and the side-menu Preview popover. + */ +export function BranchesPopoverContent({ + parentEnvironment, + branchEnvironments, + currentEnvironment, +}: { + parentEnvironment: SideMenuEnvironment; + branchEnvironments: SideMenuEnvironment[]; + currentEnvironment: SideMenuEnvironment; +}) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const { urlForEnvironment } = useEnvironmentSwitcher(); + + const activeBranches = branchEnvironments.filter((env) => env.archivedAt === null); + const state = + branchEnvironments.length === 0 + ? "no-branches" + : activeBranches.length === 0 + ? "no-active-branches" + : "has-branches"; + + // Show the archived-branch item only in the submenu it belongs to: both Development and Preview + // render this, so without the parent check an archived dev branch leaks into Preview (and vice-versa). + const currentBranchIsArchived = + environment.archivedAt !== null && environment.parentEnvironmentId === parentEnvironment.id; + + const envTextClassName = environmentTextClassName(parentEnvironment); + + return ( + <> +
+ {parentEnvironment.type === "DEVELOPMENT" ? ( + } + leadingIconClassName="text-text-dimmed" + className={ENV_POPOVER_ITEM_LABEL} + /> + ) : ( + } + leadingIconClassName="text-text-dimmed" + className={ENV_POPOVER_ITEM_LABEL} + /> + )} +
+
+ {currentBranchIsArchived && ( + - {branchEnvironments - .filter((env) => env.archivedAt === null) - .map((env) => ( - - {env.branchName ?? DEFAULT_DEV_BRANCH} - - } - icon={ - - } - isSelected={env.id === currentEnvironment.id} - /> - ))} + + {environment.branchName} + + Archived - ) : state === "no-branches" ? ( -
-
- - Create your first branch -
- - Branches are a way to test new features in isolation before merging them into the - main environment. - - - Branches are only available when using or above. Read our{" "} - v4 upgrade guide to learn - more. - -
- ) : ( -
- All branches are archived. -
- )} -
-
- {parentEnvironment.type === "DEVELOPMENT" ? ( - } - leadingIconClassName="text-text-dimmed" - /> - ) : ( - } - leadingIconClassName="text-text-dimmed" + } + icon={ + - )} + } + isSelected={environment.id === currentEnvironment.id} + /> + )} + {state === "has-branches" ? ( + <> + {branchEnvironments + .filter((env) => env.archivedAt === null) + .map((env) => ( + + {env.branchName ?? DEFAULT_DEV_BRANCH} + + } + icon={ + + } + isSelected={env.id === currentEnvironment.id} + /> + ))} + + ) : state === "no-branches" ? ( +
+
+ + Create your first branch +
+ + Branches are a way to test new features in isolation before merging them into the main + environment. + + + Branches are only available when using or above. Read our{" "} + v4 upgrade guide to learn more. +
- + ) : ( +
+ All branches are archived. +
+ )}
- + ); } diff --git a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx index cf3cb62aaa5..669271fe766 100644 --- a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx +++ b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx @@ -1,25 +1,27 @@ import { ArrowUpRightIcon } from "@heroicons/react/20/solid"; import { motion } from "framer-motion"; import { Fragment, useState } from "react"; +import { AISparkleIcon } from "~/assets/icons/AISparkleIcon"; import { BookIcon } from "~/assets/icons/BookIcon"; import { BulbIcon } from "~/assets/icons/BulbIcon"; +import { DropdownIcon } from "~/assets/icons/DropdownIcon"; import { EnvelopeIcon } from "~/assets/icons/EnvelopeIcon"; import { QuestionMarkIcon } from "~/assets/icons/QuestionMarkIcon"; import { RadarPulseIcon } from "~/assets/icons/RadarPulseIcon"; import { StarIcon } from "~/assets/icons/StarIcon"; import { useShortcutKeys } from "~/hooks/useShortcutKeys"; -import { sanitizeHttpUrl } from "~/utils/sanitizeUrl"; import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route"; import { useRecentChangelogs } from "~/routes/resources.platform-changelogs"; import { cn } from "~/utils/cn"; +import { sanitizeHttpUrl } from "~/utils/sanitizeUrl"; +import { AskAIRoot } from "../AskAI"; import { Feedback } from "../Feedback"; import { Shortcuts } from "../Shortcuts"; -import { Button } from "../primitives/Buttons"; import { Paragraph } from "../primitives/Paragraph"; import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover"; import { ShortcutKey } from "../primitives/ShortcutKey"; import { SimpleTooltip } from "../primitives/Tooltip"; -import { SideMenuItem } from "./SideMenuItem"; +import { SideMenuItem, SideMenuItemButton } from "./SideMenuItem"; export function HelpAndFeedback({ disableShortcut = false, @@ -49,135 +51,161 @@ export function HelpAndFeedback({ - - - - - + {(openAskAI) => ( + + + + + {/* + Width + opacity follow --sm-label-opacity so the label tracks a drag both + directions (no CSS transition — it would lag the per-frame writes). + */} + + Help & Feedback + + + {/* + Hover chevron, only when expanded. Its 16px width follows --sm-label-opacity so + an invisible chevron never holds width mid-drag and clips the help icon. + */} + {!isCollapsed && ( + + + + )} + + } + content={ + Help & Feedback + - - + + + {openAskAI !== undefined && ( +
+ + } + onClick={() => { + setHelpMenuOpen(false); + openAskAI(); + }} + /> +
)} - shortcut={{ key: "h" }} - variant="medium/bright" - /> - - } - content={ - - Help & Feedback - - - } - side="right" - sideOffset={8} - hidden={!isCollapsed} - buttonClassName="h-8! w-full" - asChild - disableHoverableContent - /> - - -
- -
-
- - - - - Contact us… - - } - /> -
-
- What's new - {changelogs.map((entry) => ( - - ))} - -
-
-
-
+
+ +
+
+ + + + + } + /> +
+
+ What's new + {changelogs.map((entry) => ( + + ))} + +
+ +
+ + )} + ); } diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index 4158d309458..17bc4a047bb 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -1,5 +1,6 @@ -import { ArrowLeftIcon, LinkIcon } from "@heroicons/react/24/solid"; +import { ArrowLeftIcon } from "@heroicons/react/24/solid"; import { BellIcon } from "~/assets/icons/BellIcon"; +import { ChainLinkIcon } from "~/assets/icons/ChainLinkIcon"; import { CreditCardIcon } from "~/assets/icons/CreditCardIcon"; import { PadlockIcon } from "~/assets/icons/PadlockIcon"; import { UsageIcon } from "~/assets/icons/UsageIcon"; @@ -34,7 +35,6 @@ import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route"; import { Paragraph } from "../primitives/Paragraph"; import { Badge } from "../primitives/Badge"; import { useHasAdminAccess } from "~/hooks/useUser"; -import { AskAI } from "../AskAI"; export type BuildInfo = { appVersion: string | undefined; @@ -79,11 +79,19 @@ export function OrganizationSettingsSideMenu({ Back to app
-
+
+ {isManagedCloud && ( <> )} -
@@ -241,7 +241,6 @@ export function OrganizationSettingsSideMenu({
-
); diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 6ddb5413544..6d78cd4cb9c 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -2,12 +2,18 @@ import { ArrowTopRightOnSquareIcon, ChevronRightIcon, ExclamationTriangleIcon, - PencilSquareIcon, } from "@heroicons/react/24/outline"; -import { Link, useFetcher, useNavigation } from "@remix-run/react"; +import { useFetcher, useNavigation, useSubmit } from "@remix-run/react"; import { LayoutGroup, motion } from "framer-motion"; -import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; -import simplur from "simplur"; +import { + type CSSProperties, + type PointerEvent as ReactPointerEvent, + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; import { AIChatIcon } from "~/assets/icons/AIChatIcon"; import { AIPenIcon } from "~/assets/icons/AIPenIcon"; import { ArrowLeftRightIcon } from "~/assets/icons/ArrowLeftRightIcon"; @@ -17,6 +23,7 @@ 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 { ChainLinkIcon } from "~/assets/icons/ChainLinkIcon"; import { ChartBarIcon } from "~/assets/icons/ChartBarIcon"; import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon"; import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon"; @@ -31,30 +38,48 @@ 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 { LeftSideMenuCollapsedIcon } from "~/assets/icons/LeftSideMenuCollapsedIcon"; +import { LeftSideMenuIcon } from "~/assets/icons/LeftSideMenuIcon"; 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 { ShieldIcon } from "~/assets/icons/ShieldIcon"; 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 { CreditCardIcon } from "~/assets/icons/CreditCardIcon"; +import { UserCrossIcon } from "~/assets/icons/UserCrossIcon"; +import { UserGroupIcon } from "~/assets/icons/UserGroupIcon"; +import { RolesIcon } from "~/assets/icons/RolesIcon"; +import { PadlockIcon } from "~/assets/icons/PadlockIcon"; +import { SlackIcon } from "~/assets/icons/SlackIcon"; +import { VercelLogo } from "~/components/integrations/VercelLogo"; import { Avatar } from "~/components/primitives/Avatar"; +import { UserProfilePhoto } from "~/components/UserProfilePhoto"; 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 { useShowSelfServe } from "~/hooks/useShowSelfServe"; import { useHasAdminAccess } from "~/hooks/useUser"; import { type UserWithDashboardPreferences } from "~/models/user.server"; -import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route"; +import { + useCurrentPlan, + useIsUsingRbacPlugin, + useIsUsingSsoPlugin, +} 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, + accountSecurityPath, + personalAccessTokensPath, adminPath, branchesPath, concurrencyPath, @@ -63,13 +88,19 @@ import { newOrganizationPath, newProjectPath, organizationPath, + organizationRolesPath, organizationSettingsPath, + organizationSlackIntegrationPath, + organizationSsoPath, organizationTeamPath, + organizationVercelIntegrationPath, queryPath, regionsPath, v3ApiKeysPath, v3BatchesPath, + v3BillingLimitsPath, v3BillingPath, + v3PrivateConnectionsPath, v3BulkActionsPath, v3DashboardsLandingPath, v3DeploymentsPath, @@ -89,17 +120,16 @@ import { 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 { type RenderIcon } from "../primitives/Icon"; import { Paragraph } from "../primitives/Paragraph"; +import { Badge } from "../primitives/Badge"; import { Popover, PopoverContent, PopoverMenuItem, PopoverTrigger } from "../primitives/Popover"; import { ShortcutKey } from "../primitives/ShortcutKey"; -import { TextLink } from "../primitives/TextLink"; import { SimpleTooltip, Tooltip, @@ -126,6 +156,109 @@ function getSectionCollapsed( return sideMenu?.collapsedSections?.[sectionId] ?? false; } +// Size popover items (org/project menus) to match the side-menu items, overriding the smaller +// small-menu-item defaults via tailwind-merge; icon carries the default dimmed color. +const SIDE_MENU_POPOVER_ITEM_ICON = "h-5 w-5 text-text-dimmed"; +const SIDE_MENU_POPOVER_ITEM_LABEL = "text-[0.90625rem] font-medium tracking-[-0.01em]"; + +// Impersonation accent (menu border + "Stop impersonating"). Full class strings so Tailwind's +// static scanner picks them up. +const IMPERSONATION_ACCENT = { + border: "border-yellow-500/80", + text: "text-yellow-500/80", +}; + +// --- Resizable side menu ----------------------------------------------------- +// A drag handle on the right edge resizes the menu. Width-driven visuals read two CSS variables +// written to the root each frame (no React re-render, so drags stay smooth): +// --sm-collapse: 0 (>= default width) → 1 (collapsed) +// --sm-label-opacity: 1 → 0, a faster fade curve of --sm-collapse + +/** Collapsed rail width in px (matches the previous `w-11`). */ +const COLLAPSED_WIDTH = 44; +/** The default/again-expanded width in px (matches the previous `w-56`). */ +const DEFAULT_WIDTH = 224; +/** The widest the menu can be dragged, in px. */ +const MAX_WIDTH = 400; +/** Duration of the collapse/expand/snap animation, in ms. */ +const COLLAPSE_ANIM_MS = 200; +/** Fraction of the collapse range over which labels fade to 0 (0.6 = fully faded at 60% collapsed). */ +const LABEL_FADE_FRACTION = 0.6; +/** + * Snap thresholds as collapse progress (0 = default, 1 = collapsed): release at <= threshold springs + * open, past it collapses. Separate per direction so releasing early continues the gesture. + */ +const COLLAPSE_SNAP_THRESHOLD = 0.25; +const EXPAND_SNAP_THRESHOLD = 0.9; +/** Pointer travel (px) below which a press on the handle counts as a click (toggle), not a drag. */ +const DRAG_CLICK_THRESHOLD = 4; + +/** Left/right padding of the pinned top section + scroll body, interpolated 10px → 4px by --sm-collapse. */ +const SIDE_MENU_PAD_X = `calc(0.625rem - 0.375rem * var(--sm-collapse, 0))`; +/** + * Scroll-body right padding DURING a transition (settled-open uses the reserved gutter instead). + * Interpolates from the measured gutter width (seamless handoff) to 4px collapsed; the 12px fallback + * is only for the first paint before `--sm-sb-gutter` is measured. + */ +const SIDE_MENU_SCROLL_PAD_RIGHT = `calc(var(--sm-sb-gutter, 12px) - (var(--sm-sb-gutter, 12px) - 0.25rem) * var(--sm-collapse, 0))`; +/** + * Hover chevron: its 16px width follows --sm-label-opacity so an invisible chevron never holds width + * mid-drag and pushes the row's clip edge into the icon. Opacity stays class-driven (hover-only). + */ +const SIDE_MENU_CHEVRON_STYLE = { + maxWidth: "calc(var(--sm-label-opacity, 1) * 16px)", +} as const; +/** + * Selector row label (org/project/env): opacity follows --sm-label-opacity to fade both directions + * without popping in on drag-open. The generous max-width cap fades the text in place rather than + * truncating it, but still scales to 0 so an invisible label never holds width and clips the icon. + */ +const SIDE_MENU_SELECTOR_LABEL_STYLE = { + maxWidth: "calc(var(--sm-label-opacity, 1) * 1000px)", + opacity: "var(--sm-label-opacity, 1)", +} as const; + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +/** Collapse progress (0 = at/above default width, 1 = collapsed) for a given px width. */ +function widthToProgress(width: number) { + return clamp((DEFAULT_WIDTH - width) / (DEFAULT_WIDTH - COLLAPSED_WIDTH), 0, 1); +} + +/** Label opacity (1 → 0) for a given collapse progress, using the faster fade curve. */ +function progressToLabelOpacity(progress: number) { + return clamp((LABEL_FADE_FRACTION - progress) / LABEL_FADE_FRACTION, 0, 1); +} + +/** cubic-bezier(0.4, 0, 0.2, 1) — standard easing for the rAF tween, matching the CSS transitions. */ +function easeStandard(t: number) { + // Solve the bezier for x = t, then return y. Control points: p1 = (0.4, 0), p2 = (0.2, 1). + const x1 = 0.4; + const y1 = 0; + const x2 = 0.2; + const y2 = 1; + const cx = 3 * x1; + const bx = 3 * (x2 - x1) - cx; + const ax = 1 - cx - bx; + const cy = 3 * y1; + const by = 3 * (y2 - y1) - cy; + const ay = 1 - cy - by; + const sampleX = (u: number) => ((ax * u + bx) * u + cx) * u; + const sampleY = (u: number) => ((ay * u + by) * u + cy) * u; + const sampleDerivativeX = (u: number) => (3 * ax * u + 2 * bx) * u + cx; + // Newton-Raphson to invert x(u) = t. + let u = t; + for (let i = 0; i < 6; i++) { + const x = sampleX(u) - t; + const dx = sampleDerivativeX(u); + if (Math.abs(x) < 1e-4 || Math.abs(dx) < 1e-6) break; + u -= x / dx; + } + return sampleY(clamp(u, 0, 1)); +} + type SideMenuUser = Pick< UserWithDashboardPreferences, "email" | "admin" | "dashboardPreferences" @@ -155,14 +288,45 @@ export function SideMenu({ organization, organizations, }: SideMenuProps) { - const borderRef = useRef(null); - const [showHeaderDivider, setShowHeaderDivider] = useState(false); const [isCollapsed, setIsCollapsed] = useState( user.dashboardPreferences.sideMenu?.isCollapsed ?? false ); + const [isDragging, setIsDragging] = useState(false); + // True during a click/⌘B/release-snap animation. With isDragging, marks any in-flight transition + // (the gutter is only reserved once settled — see `showReservedGutter`). + const [isAnimating, setIsAnimating] = useState(false); + + // --- Resize state (see the module constants above) --- + const rootRef = useRef(null); + const rafRef = useRef(null); + // Mirror of `isCollapsed` for the drag handlers (outside React's render cycle; no stale closures). + const isCollapsedRef = useRef(isCollapsed); + // The last-committed expanded width; animation targets and re-expansion read from here. + const expandedWidthRef = useRef( + clamp(user.dashboardPreferences.sideMenu?.width ?? DEFAULT_WIDTH, DEFAULT_WIDTH, MAX_WIDTH) + ); + // Frozen first-paint width; never changes, so React never fights the imperative width writes. + const initialWidthRef = useRef( + (user.dashboardPreferences.sideMenu?.isCollapsed ?? false) + ? COLLAPSED_WIDTH + : expandedWidthRef.current + ); + const widthRef = useRef(initialWidthRef.current); + const progressRef = useRef((user.dashboardPreferences.sideMenu?.isCollapsed ?? false) ? 1 : 0); + // Frozen initial style (incl. CSS vars) so the SSR HTML has the right collapsed/expanded visuals + // (no pre-hydration flash). Stable identity, so React never rewrites it after writeVisual. + const initialStyleRef = useRef({ + width: initialWidthRef.current, + "--sm-collapse": String(progressRef.current), + "--sm-label-opacity": String(progressToLabelOpacity(progressRef.current)), + } as CSSProperties); + // Removes an in-flight drag's window listeners (set on pointerdown; cleared on finish/unmount). + const dragCleanupRef = useRef<(() => void) | null>(null); + const preferencesFetcher = useFetcher(); const pendingPreferencesRef = useRef<{ isCollapsed?: boolean; + width?: number; sectionId?: SideMenuSectionId; sectionCollapsed?: boolean; }>({}); @@ -179,6 +343,7 @@ export function SideMenu({ const persistSideMenuPreferences = useCallback( (data: { isCollapsed?: boolean; + width?: number; sectionId?: SideMenuSectionId; sectionCollapsed?: boolean; }) => { @@ -202,6 +367,9 @@ export function SideMenu({ if (pending.isCollapsed !== undefined) { formData.append("isCollapsed", String(pending.isCollapsed)); } + if (pending.width !== undefined) { + formData.append("width", String(pending.width)); + } if (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined) { formData.append("sectionId", pending.sectionId); formData.append("sectionCollapsed", String(pending.sectionCollapsed)); @@ -216,41 +384,239 @@ export function SideMenu({ [user.isImpersonating, preferencesFetcher] ); - // Flush pending preferences on unmount to avoid losing the last toggle + // Flush routine in a ref so the unmount effect can have empty deps. `useFetcher` returns a fresh + // object each render, so depending on it would fire the cleanup (flushing the debounce) every + // render — and drags re-render constantly — instead of only on unmount. + const flushPendingPreferencesRef = useRef<() => void>(); + flushPendingPreferencesRef.current = () => { + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + if (user.isImpersonating) return; + const pending = pendingPreferencesRef.current; + const hasPendingChanges = + pending.isCollapsed !== undefined || + pending.width !== undefined || + (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined); + if (!hasPendingChanges) return; + + const formData = new FormData(); + if (pending.isCollapsed !== undefined) { + formData.append("isCollapsed", String(pending.isCollapsed)); + } + if (pending.width !== undefined) { + formData.append("width", String(pending.width)); + } + 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 = {}; + }; + + // Flush pending preferences on unmount. Empty deps so cleanup runs only on a real unmount + // (see flushPendingPreferencesRef). useEffect(() => { - return () => { - if (debounceTimeoutRef.current) { - clearTimeout(debounceTimeoutRef.current); + return () => flushPendingPreferencesRef.current?.(); + }, []); + + // Measure the reserved scrollbar-gutter width once and expose it as `--sm-sb-gutter` (the padding + // hands off to it seamlessly; platform-dependent, so it must be measured). A probe with the same + // scrollbar classes is measured so the value matches what that styling reserves. + useEffect(() => { + const el = rootRef.current; + if (!el) return; + const probe = document.createElement("div"); + probe.className = "scrollbar-gutter-stable scrollbar-thin"; + probe.style.cssText = + "position:absolute;top:-9999px;left:-9999px;width:100px;height:100px;overflow-y:auto;visibility:hidden;"; + document.body.appendChild(probe); + const gutter = probe.offsetWidth - probe.clientWidth; + document.body.removeChild(probe); + if (gutter > 0) el.style.setProperty("--sm-sb-gutter", `${gutter}px`); + }, []); + + // Write width + collapse vars straight to the DOM (no re-render) so drags stay smooth; all + // width-driven visuals (labels, headers, padding, dividers) read them. + const writeVisual = useCallback((width: number, progress: number) => { + widthRef.current = width; + progressRef.current = progress; + const el = rootRef.current; + if (!el) return; + el.style.width = `${width}px`; + el.style.setProperty("--sm-collapse", String(progress)); + el.style.setProperty("--sm-label-opacity", String(progressToLabelOpacity(progress))); + }, []); + + // Animate width + progress over COLLAPSE_ANIM_MS (toggle button, ⌘B shortcut, release-snap). + const animateTo = useCallback( + (targetWidth: number, targetProgress: number) => { + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + const startWidth = widthRef.current; + const startProgress = progressRef.current; + if (startWidth === targetWidth && startProgress === targetProgress) { + setIsAnimating(false); + return; } - if (user.isImpersonating) return; - const pending = pendingPreferencesRef.current; - const hasPendingChanges = - pending.isCollapsed !== undefined || - (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined); + setIsAnimating(true); + const startTime = performance.now(); + const step = (now: number) => { + const t = clamp((now - startTime) / COLLAPSE_ANIM_MS, 0, 1); + const eased = easeStandard(t); + writeVisual( + startWidth + (targetWidth - startWidth) * eased, + startProgress + (targetProgress - startProgress) * eased + ); + if (t < 1) { + rafRef.current = requestAnimationFrame(step); + } else { + rafRef.current = null; + writeVisual(targetWidth, targetProgress); + setIsAnimating(false); + } + }; + rafRef.current = requestAnimationFrame(step); + }, + [writeVisual] + ); - if (hasPendingChanges) { - const formData = new FormData(); - if (pending.isCollapsed !== undefined) { - formData.append("isCollapsed", String(pending.isCollapsed)); + // Collapse/expand to a resting state and remember it. + const applyCollapsed = useCallback( + (next: boolean) => { + isCollapsedRef.current = next; + setIsCollapsed(next); + persistSideMenuPreferences({ isCollapsed: next }); + animateTo(next ? COLLAPSED_WIDTH : expandedWidthRef.current, next ? 1 : 0); + }, + [animateTo, persistSideMenuPreferences] + ); + + const handleToggleCollapsed = useCallback(() => { + applyCollapsed(!isCollapsedRef.current); + }, [applyCollapsed]); + + // Drag runs on window-level listeners so releasing anywhere finalizes it. (Pointer capture alone + // was unreliable: if the browser drops it mid-drag, the release never fires and the menu strands.) + const onHandlePointerDown = useCallback( + (e: ReactPointerEvent) => { + if (e.button !== 0) return; + e.preventDefault(); + // Capture just quiets hover states while dragging; the drag doesn't depend on it. + try { + e.currentTarget.setPointerCapture(e.pointerId); + } catch {} + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + // Never allow two concurrent drags. + dragCleanupRef.current?.(); + + const drag = { + startX: e.clientX, + startWidth: rootRef.current?.getBoundingClientRect().width ?? widthRef.current, + startedCollapsed: isCollapsedRef.current, + didDrag: false, + }; + + const cleanup = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onCancel); + window.removeEventListener("blur", onCancel); + document.body.style.userSelect = ""; + document.body.style.cursor = ""; + dragCleanupRef.current = null; + }; + + const onMove = (ev: PointerEvent) => { + const dx = ev.clientX - drag.startX; + if (!drag.didDrag) { + // Ignore tiny movement so a click still reads as a click (toggle), not a drag. + if (Math.abs(dx) < DRAG_CLICK_THRESHOLD) return; + drag.didDrag = true; + setIsDragging(true); + document.body.style.userSelect = "none"; + document.body.style.cursor = "col-resize"; } - if (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined) { - formData.append("sectionId", pending.sectionId); - formData.append("sectionCollapsed", String(pending.sectionCollapsed)); + const width = clamp(drag.startWidth + dx, COLLAPSED_WIDTH, MAX_WIDTH); + writeVisual(width, widthToProgress(width)); + }; + + const onUp = () => { + cleanup(); + setIsDragging(false); + + // A press with no meaningful drag toggles the menu. + if (!drag.didDrag) { + applyCollapsed(!isCollapsedRef.current); + return; } - preferencesFetcher.submit(formData, { - method: "POST", - action: "/resources/preferences/sidemenu", - }); - pendingPreferencesRef.current = {}; - } - }; - }, [preferencesFetcher, user.isImpersonating]); - const handleToggleCollapsed = () => { - const newIsCollapsed = !isCollapsed; - setIsCollapsed(newIsCollapsed); - persistSideMenuPreferences({ isCollapsed: newIsCollapsed }); - }; + const width = widthRef.current; + // A drag that started collapsed is an opening gesture: flip the snap zone so an early + // release keeps opening. + const snapThreshold = drag.startedCollapsed + ? EXPAND_SNAP_THRESHOLD + : COLLAPSE_SNAP_THRESHOLD; + if (width >= DEFAULT_WIDTH) { + // Rest at the dragged width. + const rounded = Math.round(width); + expandedWidthRef.current = rounded; + isCollapsedRef.current = false; + setIsCollapsed(false); + persistSideMenuPreferences({ isCollapsed: false, width: rounded }); + writeVisual(rounded, 0); + } else if (widthToProgress(width) <= snapThreshold) { + // Released near the default width — spring back open. + expandedWidthRef.current = DEFAULT_WIDTH; + isCollapsedRef.current = false; + setIsCollapsed(false); + persistSideMenuPreferences({ isCollapsed: false, width: DEFAULT_WIDTH }); + animateTo(DEFAULT_WIDTH, 0); + } else { + // Released deeper in (or over-dragged past min width) — collapse the rest of the way. + isCollapsedRef.current = true; + setIsCollapsed(true); + persistSideMenuPreferences({ isCollapsed: true }); + animateTo(COLLAPSED_WIDTH, 1); + } + }; + + const onCancel = () => { + cleanup(); + setIsDragging(false); + if (!drag.didDrag) return; + // Settle back to the current resting state. + animateTo( + isCollapsedRef.current ? COLLAPSED_WIDTH : expandedWidthRef.current, + isCollapsedRef.current ? 1 : 0 + ); + }; + + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onCancel); + window.addEventListener("blur", onCancel); + dragCleanupRef.current = cleanup; + }, + [animateTo, applyCollapsed, persistSideMenuPreferences, writeVisual] + ); + + // Keep the drag handlers' collapsed mirror in sync; tear down any in-flight animation/drag on unmount. + useEffect(() => { + isCollapsedRef.current = isCollapsed; + }, [isCollapsed]); + useEffect(() => { + return () => { + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + dragCleanupRef.current?.(); + }; + }, []); /** Generic handler for any collapsible section - just pass the section ID */ const handleSectionToggle = useCallback( @@ -265,95 +631,67 @@ export function SideMenu({ 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]); + // Reserve the scrollbar gutter only when fully settled open (stops the list shifting as it starts/ + // stops overflowing). Dropped mid-transition so the right padding can animate instead of a fixed + // gutter snapping away (see SIDE_MENU_SCROLL_PAD_RIGHT). + const showReservedGutter = !isCollapsed && !isDragging && !isAnimating; return (
- -
-
-
- +
+
+
+
- {isAdmin && !user.isImpersonating ? ( - - - - - - - - Admin dashboard - - - - - ) : isAdmin && user.isImpersonating ? ( - - - - ) : null} + + +
-
-
- + +
+
{environment.type === "DEVELOPMENT" && project.engine === "V2" && ( - + @@ -363,6 +701,13 @@ export function SideMenu({
- +
+
+
+
-
@@ -697,8 +1051,10 @@ export function SideMenu({ > {isFreeUser && ( @@ -809,30 +1165,34 @@ function V3DeprecationContent() { ); } -function ProjectSelector({ - project, +function OrgSelector({ organization, organizations, - user, isCollapsed = false, + isDragging = false, + isAdmin, + isImpersonating, }: { - project: SideMenuProject; organization: MatchedOrganization; organizations: MatchedOrganization[]; - user: SideMenuUser; isCollapsed?: boolean; + /** True while the menu is being drag-resized; keeps the row in its expanded arrangement. */ + isDragging?: boolean; + /** Account context, only used to render the collapsed-rail "Account" submenu (see below). */ + isAdmin: boolean; + isImpersonating: boolean; }) { const currentPlan = useCurrentPlan(); const [isOrgMenuOpen, setOrgMenuOpen] = useState(false); const navigation = useNavigation(); const { isManagedCloud } = useFeatures(); + const featureFlags = useFeatureFlags(); + const showSelfServe = useShowSelfServe(); + const isUsingRbacPlugin = useIsUsingRbacPlugin(); + const isUsingSsoPlugin = useIsUsingSsoPlugin(); - let plan: string | undefined = undefined; - if (currentPlan?.v3Subscription?.isPaying === false) { - plan = "Free"; - } else if (currentPlan?.v3Subscription?.isPaying === true) { - plan = currentPlan.v3Subscription.plan?.title; - } + const isPaying = currentPlan?.v3Subscription?.isPaying === true; + const planTitle = currentPlan?.v3Subscription?.plan?.title; useEffect(() => { setOrgMenuOpen(false); @@ -844,40 +1204,37 @@ function ProjectSelector({ button={ - - - {project.name ?? "Select a project"} + + {organization.title} - + } - content={`${organization.title} / ${project.name ?? "Select a project"}`} + content={organization.title} side="right" sideOffset={8} hidden={!isCollapsed} buttonClassName="h-8!" asChild + tabbable disableHoverableContent /> -
-
- - -
- -
- -
- {organization.title} -
- {plan && ( - - {plan} plan - - )} - {simplur`${organization.membersCount} member[|s]`} -
-
-
-
- - - Settings - - {isManagedCloud && ( - - - Usage - - )} +
+ + {isManagedCloud && ( + + )} + {isManagedCloud && ( + + Billing + {isPaying && planTitle ? {planTitle} : null} +
+ } + icon={CreditCardIcon} + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} + /> + )} + {isManagedCloud && showSelfServe && ( + + )} + + {featureFlags.hasPrivateConnections && ( + + )} + {isUsingRbacPlugin && ( + + )} + {isUsingSsoPlugin && ( + + )} + +
+
+ {organizations.length > 1 ? ( + + ) : ( + + )} +
+ {/* Collapsed: the account button is hidden, so surface Account as a submenu here (the only + always-reachable menu on the rail). */} + {isCollapsed && ( +
+ }> + +
+ )} + + + ); +} + +/** + * Account menu entries, shared by the standalone account popover (expanded rail) and the "Account" + * submenu in the org popover (collapsed rail). + */ +function AccountMenuItems({ + isAdmin, + isImpersonating, +}: { + isAdmin: boolean; + isImpersonating: boolean; +}) { + const submit = useSubmit(); + const stopImpersonating = () => + submit(null, { action: "/resources/impersonation", method: "delete" }); + + return ( + <> + {isAdmin && ( +
+ {isImpersonating ? ( + + Stop impersonating + +
+ } + icon={UserCrossIcon} + onClick={stopImpersonating} + leadingIconClassName={cn(SIDE_MENU_POPOVER_ITEM_ICON, IMPERSONATION_ACCENT.text)} + className={SIDE_MENU_POPOVER_ITEM_LABEL} + /> + ) : ( + + Admin dashboard + +
+ } + icon={HomeIcon} + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} + /> + )}
+ )} +
+ + + +
+
+ +
+ + ); +} + +function AccountMenu({ isAdmin, isImpersonating }: { isAdmin: boolean; isImpersonating: boolean }) { + const [isOpen, setIsOpen] = useState(false); + const navigation = useNavigation(); + + useEffect(() => { + setIsOpen(false); + }, [navigation.location?.pathname]); + + // The admin shortcut lives in so it works everywhere, not just where this menu is. + return ( + setIsOpen(open)} open={isOpen}> + + + + } + content="Account" + side="bottom" + sideOffset={8} + asChild + tabbable + disableHoverableContent + /> + + + + + ); +} + +function ProjectSelector({ + project, + organization, + environment, + isCollapsed = false, + isDragging = false, + className, +}: { + project: SideMenuProject; + organization: MatchedOrganization; + environment: SideMenuEnvironment; + isCollapsed?: boolean; + /** True while the menu is being drag-resized; keeps the row in its expanded arrangement. */ + isDragging?: boolean; + className?: string; +}) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const navigation = useNavigation(); + + useEffect(() => { + setIsMenuOpen(false); + }, [navigation.location?.pathname]); + + return ( + setIsMenuOpen(open)} open={isMenuOpen}> + + + + + + {project.name ?? "Select a project"} + + + + + + + + } + content={project.name ?? "Select a project"} + side="right" + sideOffset={8} + hidden={!isCollapsed} + buttonClassName="h-8!" + asChild + tabbable + disableHoverableContent + /> +
+ + +
+
{organization.projects.map((p) => { const isSelected = p.id === project.id; return ( @@ -957,99 +1577,72 @@ function ProjectSelector({ } isSelected={isSelected} icon={isSelected ? FolderOpenIcon : FolderClosedIcon} - leadingIconClassName="text-indigo-500" + leadingIconClassName="h-5 w-5 text-indigo-500" + className={SIDE_MENU_POPOVER_ITEM_LABEL} /> ); })} - -
-
- {organizations.length > 1 ? ( - - ) : ( - - )} -
-
- -
-
-
); } -function SwitchOrganizations({ - organizations, - organization, +/** + * Hover-expandable submenu row for side-menu popovers (Account, Switch organization, Integrations): + * a menu item with a trailing chevron that reveals `children` in a popover to the right, with a + * short close delay so the pointer can cross the gap. + */ +function SideMenuPopoverSubMenu({ + title, + icon, + leadingIconClassName, + children, }: { - organizations: MatchedOrganization[]; - organization: MatchedOrganization; + title: string; + icon: RenderIcon; + leadingIconClassName?: string; + children: ReactNode; }) { const navigation = useNavigation(); - const [isMenuOpen, setMenuOpen] = useState(false); + const [isOpen, setIsOpen] = useState(false); const timeoutRef = useRef(null); - // Clear timeout on unmount useEffect(() => { return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } + if (timeoutRef.current) clearTimeout(timeoutRef.current); }; }, []); + // Close the submenu on navigation (the parent popover closes too). useEffect(() => { - setMenuOpen(false); + setIsOpen(false); }, [navigation.location?.pathname]); - const handleMouseEnter = () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - setMenuOpen(true); + const openNow = () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + setIsOpen(true); }; - - const handleMouseLeave = () => { - // Small delay before closing to allow moving to the content - timeoutRef.current = setTimeout(() => { - setMenuOpen(false); - }, 150); + const closeSoon = () => { + // Small delay before closing so the pointer can move onto the content. + timeoutRef.current = setTimeout(() => setIsOpen(false), 150); }; return ( - setMenuOpen(open)} open={isMenuOpen}> -
+ setIsOpen(open)} open={isOpen}> +
- Switch organization + {title} -
- {organizations.map((org) => ( - } - leadingIconClassName="text-text-dimmed" - isSelected={org.id === organization.id} - /> - ))} -
-
- -
+ {children}
); } -function SelectorDivider() { +function SwitchOrganizations({ + organizations, + organization, +}: { + organizations: MatchedOrganization[]; + organization: MatchedOrganization; +}) { + return ( + +
+ +
+
+ {organizations.map((org) => ( + } + leadingIconClassName="text-text-dimmed" + className={SIDE_MENU_POPOVER_ITEM_LABEL} + isSelected={org.id === organization.id} + /> + ))} +
+
+ ); +} + +function Integrations({ organization }: { organization: MatchedOrganization }) { return ( - - - + +
+ + +
+
); } -/** Helper component that fades out but preserves width (collapses to 0 width) */ +/** + * Fades out and collapses to 0 width via the menu's `--sm-label-opacity` variable, tracking a drag + * in real time (no CSS opacity transition — it would lag the per-frame variable writes). + */ function CollapsibleElement({ - isCollapsed, + isDragging = false, children, className, }: { - isCollapsed: boolean; + /** Only blocks clicks on the fading button mid-drag; the hiding is width+opacity below. */ + isDragging?: boolean; children: ReactNode; className?: string; }) { + // Width AND opacity follow --sm-label-opacity: opacity alone would leave the invisible button + // holding 32px of row width, pushing the primary item's clip edge into its icon ("masked" mid-drag). + // Shrinking width on the same curve hands that space back. No CSS transition (it would lag the writes). return (
{children}
@@ -1151,12 +1775,16 @@ function CollapsibleHeight({ function HelpAndAI({ isCollapsed, + isDragging, organizationId, projectId, + onToggleCollapsed, }: { isCollapsed: boolean; + isDragging: boolean; organizationId: string; projectId: string; + onToggleCollapsed: () => void; }) { return ( @@ -1172,126 +1800,77 @@ function HelpAndAI({ organizationId={organizationId} projectId={projectId} /> - +
); } -function AnimatedChevron({ - isHovering, +function CollapseMenuButton({ isCollapsed, + isDragging = false, + onToggle, }: { - isHovering: boolean; isCollapsed: boolean; + isDragging?: boolean; + onToggle: () => void; }) { - // 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 */} -
+ // Shrink-and-fade only while dragging CLOSED, where this sits beside Help & Feedback and would + // overlap it as the row narrows. Dragging OPEN it stays put: collapsed, this IS the expand + // affordance, and the 0->1 variable would make the icon grow from nothing. At rest: natural size. +
- + - + + - + {isCollapsed ? "Expand" : "Collapse"} @@ -1303,3 +1882,71 @@ function CollapseToggle({ isCollapsed, onToggle }: { isCollapsed: boolean; onTog
); } + +/** + * Resize affordance straddling the menu's right border: hover reveals an indigo line, drag resizes, + * click toggles collapsed/expanded, and the tooltip follows the pointer's Y. The strip extends 4px + * past the edge, so the menu root deliberately has no overflow-hidden (only its inner grid does). + */ +function ResizeHandle({ + isCollapsed, + isDragging, + onPointerDown, +}: { + isCollapsed: boolean; + isDragging: boolean; + onPointerDown: (e: ReactPointerEvent) => void; +}) { + // Fully controlled so open never flips controlled/uncontrolled mid-interaction; open requests + // during a drag are dropped. + const [isTooltipOpen, setTooltipOpen] = useState(false); + // Pointer Y within the strip — anchors the tooltip beside the cursor, not the strip's center. + const [anchorY, setAnchorY] = useState(0); + + return ( + + setTooltipOpen(open && !isDragging)} + > + +
{ + if (isDragging) return; + setAnchorY(Math.round(e.clientY - e.currentTarget.getBoundingClientRect().top)); + }} + className="group/resize absolute inset-y-0 -right-1 z-30 w-2 cursor-col-resize touch-none" + > +
+
+ + + Drag to resize + + {isCollapsed ? "Click to expand" : "Click to collapse"} + + + + + + + + + ); +} diff --git a/apps/webapp/app/components/navigation/SideMenuHeader.tsx b/apps/webapp/app/components/navigation/SideMenuHeader.tsx index 80143e9e0d9..8ccbfa7de70 100644 --- a/apps/webapp/app/components/navigation/SideMenuHeader.tsx +++ b/apps/webapp/app/components/navigation/SideMenuHeader.tsx @@ -40,15 +40,8 @@ export function SideMenuHeader({

{visiblePart} {fadingPart && ( - - {fadingPart} - + // --sm-label-opacity morphs "Project" → "Proj" as the menu narrows (unset elsewhere → 1). + {fadingPart} )}

{children !== undefined ? ( diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx index c47f949ab40..f17051266d4 100644 --- a/apps/webapp/app/components/navigation/SideMenuItem.tsx +++ b/apps/webapp/app/components/navigation/SideMenuItem.tsx @@ -1,4 +1,9 @@ -import { type AnchorHTMLAttributes, type ReactNode } from "react"; +import { + type AnchorHTMLAttributes, + type ButtonHTMLAttributes, + forwardRef, + type ReactNode, +} from "react"; import { Link } from "@remix-run/react"; import { motion } from "framer-motion"; import { usePathName } from "~/hooks/usePathName"; @@ -14,6 +19,7 @@ export function SideMenuItem({ trailingIcon, trailingIconClassName, name, + nameClassName, to, badge, target, @@ -30,18 +36,14 @@ export function SideMenuItem({ trailingIcon?: RenderIcon; trailingIconClassName?: string; name: string; + nameClassName?: string; to: string; badge?: ReactNode; target?: AnchorHTMLAttributes["target"]; isCollapsed?: boolean; action?: ReactNode; disableIconHover?: boolean; - /** - * Visually indented variant — same item, just pushed further from - * the left edge so it reads as a child of the row above. Used for - * grouped sub-items like the Tasks > (Agents / Standard / Scheduled) - * cluster. The indent is only applied when the side menu is expanded. - */ + /** Indented variant for grouped sub-items; only applied when the menu is expanded. */ indented?: boolean; "data-action"?: string; }) { @@ -56,7 +58,7 @@ export function SideMenuItem({ target={target} data-action={dataAction} className={cn( - "group/menulink flex h-8 items-center gap-2 overflow-hidden rounded pl-1.75 pr-2", + "group/menulink flex h-8 items-center gap-2 overflow-hidden rounded pl-1.75 pr-2 focus-custom", isIndented ? "min-w-0 flex-1" : "w-full", isActive ? "bg-tertiary text-text-bright" @@ -75,32 +77,39 @@ export function SideMenuItem({ )} /> - - {name} - - {badge && !isCollapsed && ( - + - {badge} - - )} - {trailingIcon && !isCollapsed && ( - - )} + {name} + + {badge && !isCollapsed && ( +
{badge}
+ )} + {trailingIcon && !isCollapsed && ( + + )} +
); @@ -125,9 +134,11 @@ export function SideMenuItem({ buttonClassName="h-8! block w-full" hidden={!isCollapsed} asChild + tabbable disableHoverableContent /> {!isCollapsed && ( + // Fades with the labels via --sm-label-opacity (unset → fully visible).
{action}
@@ -152,7 +164,35 @@ export function SideMenuItem({ buttonClassName="h-8! block w-full" hidden={!isCollapsed} asChild + tabbable disableHoverableContent /> ); } + +/** Button styled to match {@link SideMenuItem}, for entries that open a dialog rather than navigate. */ +export const SideMenuItemButton = forwardRef< + HTMLButtonElement, + { icon: RenderIcon; name: string; trailing?: ReactNode } & ButtonHTMLAttributes +>(function SideMenuItemButton({ icon, name, trailing, className, type, ...props }, ref) { + return ( + + ); +}); diff --git a/apps/webapp/app/components/navigation/SideMenuSection.tsx b/apps/webapp/app/components/navigation/SideMenuSection.tsx index 70b5e099538..8225d9e701c 100644 --- a/apps/webapp/app/components/navigation/SideMenuSection.tsx +++ b/apps/webapp/app/components/navigation/SideMenuSection.tsx @@ -1,5 +1,5 @@ import { AnimatePresence, motion } from "framer-motion"; -import React, { useCallback, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { ToggleArrowIcon } from "~/assets/icons/ToggleArrowIcon"; type Props = { @@ -14,9 +14,7 @@ type Props = { headerAction?: React.ReactNode; }; -/** A collapsible section for the side menu - * The collapsed state is passed in as a prop, and there's a callback when it's toggled so we can save the state. - */ +/** A collapsible section for the side menu. Collapsed state is controlled via props + a toggle callback. */ export function SideMenuSection({ title, initialCollapsed = false, @@ -27,6 +25,7 @@ export function SideMenuSection({ headerAction, }: Props) { const [isCollapsed, setIsCollapsed] = useState(initialCollapsed); + const contentRef = useRef(null); const handleToggle = useCallback(() => { const newIsCollapsed = !isCollapsed; @@ -34,22 +33,37 @@ export function SideMenuSection({ onCollapseToggle?.(newIsCollapsed); }, [isCollapsed, onCollapseToggle]); + // Collapsed items stay in the DOM (height 0) for the animation, so `inert` removes them from the + // tab order and a11y tree (it doesn't affect layout). Set the DOM property directly — React 18's + // `inert` prop handling is unreliable. + useEffect(() => { + if (contentRef.current) { + contentRef.current.inert = isCollapsed; + } + }, [isCollapsed]); + return (
{/* Header container - stays in DOM to preserve height */}
- {/* Header - fades out when sidebar is collapsed */} -