From c6138e38ae6850b594206925a2197476cd8a781c Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 28 Jun 2026 20:15:52 +0100 Subject: [PATCH 1/3] feat(webapp): split the side menu project and organization menus The top-left menu now represents the organization: it shows the org name and avatar and opens a flat list of org-level pages (Settings, Usage, Billing, Billing alerts, Team, an Integrations submenu, Switch organization, Account, Logout). Projects move into a dedicated "Project" section in the menu body: a project picker dropdown with the environment selector directly beneath it. --- .../side-menu-project-and-org-menus.md | 20 + .../navigation/EnvironmentSelector.tsx | 1 + .../app/components/navigation/SideMenu.tsx | 381 +++++++++++++----- .../_app.orgs.$organizationSlug/route.tsx | 39 +- 4 files changed, 328 insertions(+), 113 deletions(-) create mode 100644 .server-changes/side-menu-project-and-org-menus.md 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..d64f0fd95e0 --- /dev/null +++ b/.server-changes/side-menu-project-and-org-menus.md @@ -0,0 +1,20 @@ +--- +area: webapp +type: improvement +--- + +Restructure the side menu's top-left and project/organization navigation: + +- 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, then Account and Logout) using the same icons and links as the + organization settings side menu. + +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/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index 40174acfe07..f1d340b35ed 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -111,6 +111,7 @@ export function EnvironmentSelector({ align="start" style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }} > +
{project.environments .filter((env) => env.parentEnvironmentId === null) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 619d2477a4a..c59b9eb9ecf 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -2,13 +2,12 @@ import { ArrowTopRightOnSquareIcon, ChevronRightIcon, ExclamationTriangleIcon, - PencilSquareIcon, } from "@heroicons/react/24/outline"; -import { Link, useFetcher, useNavigation } from "@remix-run/react"; +import { LinkIcon } from "@heroicons/react/24/solid"; +import { useFetcher, useNavigation } from "@remix-run/react"; import { BugIcon } from "~/assets/icons/BugIcon"; 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"; @@ -41,6 +40,12 @@ import { TasksIcon } from "~/assets/icons/TasksIcon"; import { BellIcon } from "~/assets/icons/BellIcon"; import { UsageIcon } from "~/assets/icons/UsageIcon"; import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; +import { CreditCardIcon } from "~/assets/icons/CreditCardIcon"; +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 { type MatchedEnvironment } from "~/hooks/useEnvironment"; import { useFeatureFlags } from "~/hooks/useFeatureFlags"; @@ -48,9 +53,14 @@ 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 { NotificationPanel } from "./NotificationPanel"; @@ -65,13 +75,19 @@ import { newOrganizationPath, newProjectPath, organizationPath, + organizationRolesPath, organizationSettingsPath, + organizationSlackIntegrationPath, + organizationSsoPath, organizationTeamPath, + organizationVercelIntegrationPath, queryPath, regionsPath, v3ApiKeysPath, v3BatchesPath, + v3BillingLimitsPath, v3BillingPath, + v3PrivateConnectionsPath, v3DashboardsLandingPath, v3BulkActionsPath, v3DeploymentsPath, @@ -99,9 +115,9 @@ import { ImpersonationBanner } from "../ImpersonationBanner"; import { Button, ButtonContent, LinkButton } from "../primitives/Buttons"; import { Dialog, DialogTrigger } from "../primitives/Dialog"; 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, @@ -297,11 +313,9 @@ export function SideMenu({ )} >
-
@@ -341,10 +355,12 @@ export function SideMenu({ >
- +
{ setOrgMenuOpen(false); @@ -858,9 +870,8 @@ function ProjectSelector({ isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100" )} > - - {project.name ?? "Select a project"} + {organization.title} @@ -874,7 +885,7 @@ function ProjectSelector({ } - content={`${organization.title} / ${project.name ?? "Select a project"}`} + content={organization.title} side="right" sideOffset={8} hidden={!isCollapsed} @@ -889,83 +900,73 @@ function ProjectSelector({ align="start" style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }} > -
-
- - -
- -
- -
- {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" - /> - ); - })} - -
-
+ + {isManagedCloud && ( + + )} + {isManagedCloud && ( + + Billing + {isPaying && planTitle ? {planTitle} : null} +
+ } + icon={CreditCardIcon} + leadingIconClassName="text-text-dimmed" + /> + )} + {isManagedCloud && showSelfServe && ( + + )} + + {featureFlags.hasPrivateConnections && ( + + )} + {isUsingRbacPlugin && ( + + )} + {isUsingSsoPlugin && ( + + )} + {organizations.length > 1 ? ( ) : ( @@ -999,6 +1000,98 @@ function ProjectSelector({ ); } +function ProjectSelector({ + project, + organization, + isCollapsed = false, + className, +}: { + project: SideMenuProject; + organization: MatchedOrganization; + isCollapsed?: 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 + disableHoverableContent + /> + +
+ {organization.projects.map((p) => { + const isSelected = p.id === project.id; + return ( + + {p.name} +
+ } + isSelected={isSelected} + icon={isSelected ? FolderOpenIcon : FolderClosedIcon} + leadingIconClassName="text-indigo-500" + /> + ); + })} + +
+ + + ); +} + function SwitchOrganizations({ organizations, organization, @@ -1090,18 +1183,82 @@ function SwitchOrganizations({ ); } -function SelectorDivider() { +function Integrations({ organization }: { 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}> +
+ + + Integrations + + + +
+ + +
+
+
+
); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx index ebed8944115..fc1a6661757 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx @@ -11,6 +11,7 @@ import { RegionsPresenter, type Region } from "~/presenters/v3/RegionsPresenter. import { getImpersonationId } from "~/services/impersonation.server"; import { getCachedUsage, getBillingLimit, getCurrentPlan } from "~/services/platform.v3.server"; import { rbac } from "~/services/rbac.server"; +import { ssoController } from "~/services/sso.server"; import { canManageBilling } from "~/services/routeBuilders/permissions.server"; import { requireUser } from "~/services/session.server"; import { telemetry } from "~/services/telemetry.server"; @@ -33,6 +34,26 @@ export function useCurrentPlan(matches?: UIMatch[]) { return data?.currentPlan; } +/** Whether the optional RBAC plugin is installed (gates the Roles UI). */ +export function useIsUsingRbacPlugin(matches?: UIMatch[]) { + const data = useTypedMatchesData({ + id: "routes/_app.orgs.$organizationSlug", + matches, + }); + + return data?.isUsingRbacPlugin ?? false; +} + +/** Whether the optional SSO plugin is installed (gates the SSO UI). */ +export function useIsUsingSsoPlugin(matches?: UIMatch[]) { + const data = useTypedMatchesData({ + id: "routes/_app.orgs.$organizationSlug", + matches, + }); + + return data?.isUsingSsoPlugin ?? false; +} + export const shouldRevalidate: ShouldRevalidateFunction = (params) => { const { currentParams, nextParams } = params; @@ -98,7 +119,16 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const shouldLoadRegions = !!projectParam && !!environment && environment.type !== "DEVELOPMENT"; - const [sessionAuth, plan, usage, billingLimit, customDashboards, regions] = await Promise.all([ + const [ + sessionAuth, + plan, + usage, + billingLimit, + customDashboards, + regions, + isUsingRbacPlugin, + isUsingSsoPlugin, + ] = await Promise.all([ rbac .authenticateSession(request, { userId: user.id, @@ -123,6 +153,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { .then(({ regions }) => regions) .catch(() => [] as Region[]) : Promise.resolve([] as Region[]), + // Resolve which optional plugins are installed so the side menu can gate the + // Roles (RBAC) and SSO items the same way the org settings side menu does. + // Both calls are cheap and cached after the first resolution. + rbac.isUsingPlugin().catch(() => false), + ssoController.isUsingPlugin().catch(() => false), ]); const userCanManageBilling = sessionAuth.ok ? canManageBilling(sessionAuth.ability) : false; @@ -182,6 +217,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }, widgetLimitPerDashboard, canManageBilling: userCanManageBilling, + isUsingRbacPlugin, + isUsingSsoPlugin, }); }; From f2e8a0ea353a935043578ef49c87a052d3b3f29e Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 28 Jun 2026 20:54:01 +0100 Subject: [PATCH 2/3] feat(webapp): dim project menu trigger and add environment tooltip The Project menu trigger now matches the other side menu items: its label and folder icon are dimmed by default and brighten on hover, instead of staying highlighted. The environment selector shows a "[name] environment" tooltip on hover. It opens instantly when the side menu is collapsed and after a short delay when expanded. --- apps/webapp/app/components/navigation/EnvironmentSelector.tsx | 4 ++-- apps/webapp/app/components/navigation/SideMenu.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index f1d340b35ed..e3feffb5921 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -96,10 +96,10 @@ export function EnvironmentSelector({ } - content={environmentFullTitle(environment)} + content={`${environmentFullTitle(environment)} environment`} side="right" sideOffset={8} - hidden={!isCollapsed} + delayDuration={isCollapsed ? 0 : 500} buttonClassName="!h-8" asChild disableHoverableContent diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index c59b9eb9ecf..df3cae14091 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -1030,14 +1030,14 @@ function ProjectSelector({ )} > - + - + {project.name ?? "Select a project"} From 440023b744cf2c62bb8632655851a56d027c3f64 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 28 Jun 2026 21:37:51 +0100 Subject: [PATCH 3/3] feat(webapp): connect the environment selector to the project menu Add an end tree-connector to the left of the environment selector and remove the gap between it and the project dropdown, so the environment reads as nested under the project in the side menu. --- .../navigation/EnvironmentSelector.tsx | 19 +++++ .../app/components/navigation/SideMenu.tsx | 81 ++++++++++--------- 2 files changed, 61 insertions(+), 39 deletions(-) diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index e3feffb5921..6701c072131 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -41,12 +41,16 @@ export function EnvironmentSelector({ environment, className, isCollapsed = false, + showConnector = false, }: { organization: MatchedOrganization; project: SideMenuProject; environment: SideMenuEnvironment; className?: string; isCollapsed?: boolean; + /** Show an end tree-connector to the left of the icon so the selector reads + * as connected to the Project menu above it. Only used in the side menu. */ + showConnector?: boolean; }) { const { isManagedCloud } = useFeatures(); const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -71,6 +75,21 @@ export function EnvironmentSelector({ className )} > + {showConnector && + !isCollapsed && ( + // End tree-connector sized to the full button height (viewBox matches the + // 20x32 box) so the vertical line reaches the button's top edge and meets the + // Project button above, with the corner aligned to the environment icon's center. + + + + + )}
- -
- + - {environment.type === "DEVELOPMENT" && project.engine === "V2" && ( - - - - - -
- -
-
- - {isConnected === undefined - ? "Checking connection…" - : isConnected - ? "Your dev server is connected" - : "Your dev server is not connected"} - -
-
- -
-
- )} +
+ + {environment.type === "DEVELOPMENT" && project.engine === "V2" && ( + + + + + +
+ +
+
+ + {isConnected === undefined + ? "Checking connection…" + : isConnected + ? "Your dev server is connected" + : "Your dev server is not connected"} + +
+
+ +
+
+ )} +