From c6138e38ae6850b594206925a2197476cd8a781c Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 28 Jun 2026 20:15:52 +0100 Subject: [PATCH 01/16] 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 02/16] 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 03/16] 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"} + +
+
+ +
+
+ )} +
From d7c6af0367ac119c3bd0afb2b5598133a22c33c0 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 29 Jun 2026 19:02:11 +0100 Subject: [PATCH 04/16] feat(webapp): size side menu popover items to match the nav items Bump the org menu and project picker popover items to a 20px leading icon and a 0.90625rem label so they match the side menu nav items. The project folder icons keep their indigo color and the Vercel and Slack icons keep their smaller size. --- .../app/components/navigation/SideMenu.tsx | 69 ++++++++++++++----- 1 file changed, 50 insertions(+), 19 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index e38fa86577d..f032b460b03 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -144,6 +144,15 @@ function getSectionCollapsed( return sideMenu?.collapsedSections?.[sectionId] ?? false; } +// Size the side menu popover items (org menu + project picker) to match the side +// menu items: a 20px leading icon and a 0.90625rem label (vs the smaller +// small-menu-item defaults). The icon class overrides the variant icon size; the +// label class lands on the button element and overrides its text-2sm via +// tailwind-merge. The icon constant also carries the default dimmed color; items +// that need a different icon color (e.g. the indigo project folders) set their own. +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]"; + type SideMenuUser = Pick< UserWithDashboardPreferences, "email" | "admin" | "dashboardPreferences" @@ -908,14 +917,16 @@ function OrgSelector({ to={organizationSettingsPath(organization)} title="Settings" icon={SlidersIcon} - leadingIconClassName="text-text-dimmed" + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} /> {isManagedCloud && ( )} {isManagedCloud && ( @@ -928,7 +939,8 @@ function OrgSelector({
} icon={CreditCardIcon} - leadingIconClassName="text-text-dimmed" + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} /> )} {isManagedCloud && showSelfServe && ( @@ -936,21 +948,24 @@ function OrgSelector({ to={v3BillingLimitsPath(organization)} title="Billing alerts" icon={BellIcon} - leadingIconClassName="text-text-dimmed" + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} /> )} {featureFlags.hasPrivateConnections && ( )} {isUsingRbacPlugin && ( @@ -958,7 +973,8 @@ function OrgSelector({ to={organizationRolesPath(organization)} title="Roles" icon={RolesIcon} - leadingIconClassName="text-text-dimmed" + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} /> )} {isUsingSsoPlugin && ( @@ -966,7 +982,8 @@ function OrgSelector({ to={organizationSsoPath(organization)} title="SSO" icon={PadlockIcon} - leadingIconClassName="text-text-dimmed" + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} /> )} @@ -977,7 +994,8 @@ function OrgSelector({ to={newOrganizationPath()} title="New organization" icon={PlusIcon} - leadingIconClassName="text-text-dimmed" + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} /> )}
@@ -986,7 +1004,8 @@ function OrgSelector({ to={accountPath()} title="Account" icon={AvatarCircleIcon} - leadingIconClassName="text-text-dimmed" + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} />
@@ -994,7 +1013,8 @@ function OrgSelector({ to={logoutPath()} title="Logout" icon={ArrowRightSquareIcon} - leadingIconClassName="text-text-dimmed" + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} danger />
@@ -1084,11 +1104,18 @@ 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} /> ); })} - + @@ -1139,9 +1166,9 @@ function SwitchOrganizations({ } + icon={} leadingIconClassName="text-text-dimmed" + className={SIDE_MENU_POPOVER_ITEM_LABEL} isSelected={org.id === organization.id} /> ))} @@ -1177,7 +1205,8 @@ function SwitchOrganizations({ to={newOrganizationPath()} title="New organization" icon={PlusIcon} - leadingIconClassName="text-text-dimmed" + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} /> @@ -1224,9 +1253,9 @@ function Integrations({ organization }: { organization: MatchedOrganization }) { From 3aa2886958b90fecdfc2206342a7e61c8bb79a58 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 30 Jun 2026 14:18:21 +0100 Subject: [PATCH 05/16] feat(webapp): pin the project and environment selectors above the scroll area Move the Project header, project picker, and environment selector into a fixed region so they stay put while the nav list scrolls, and shift the scroll divider down to the top of the scrollable section. --- .../app/components/navigation/SideMenu.tsx | 117 +++++++++--------- 1 file changed, 59 insertions(+), 58 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index f032b460b03..4ea1178c6da 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -314,13 +314,8 @@ export function SideMenu({ )} > -
-
+
+
-
-
- -
- + +
+ +
+ -
- - {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"} + +
+
+ +
+
+ )}
- +
+
+
+
Date: Tue, 30 Jun 2026 18:02:55 +0100 Subject: [PATCH 06/16] feat(webapp): make the side menu env switcher a segmented control The side menu environment switcher is now a segmented control (Dev, Staging, Prev, Prod) instead of a dropdown, with each segment tinted in its environment color when active. The Dev segment shows the local dev server connection as a small pulsing dot with a hover tooltip, replacing the old connection button and modal. Preview opens a popover to switch branches and shows the active branch name. When the side menu is collapsed it falls back to the original dropdown selector, which is also still used on the blank-state panels and the Limits page. --- .../EnvironmentSegmentedControl.tsx | 241 ++++++++++++++++++ .../navigation/EnvironmentSelector.tsx | 240 ++++++++--------- .../app/components/navigation/SideMenu.tsx | 84 +----- 3 files changed, 378 insertions(+), 187 deletions(-) create mode 100644 apps/webapp/app/components/navigation/EnvironmentSegmentedControl.tsx diff --git a/apps/webapp/app/components/navigation/EnvironmentSegmentedControl.tsx b/apps/webapp/app/components/navigation/EnvironmentSegmentedControl.tsx new file mode 100644 index 00000000000..f6d82b5225e --- /dev/null +++ b/apps/webapp/app/components/navigation/EnvironmentSegmentedControl.tsx @@ -0,0 +1,241 @@ +import { Link, useNavigation } from "@remix-run/react"; +import { motion } from "framer-motion"; +import { useEffect, useState } from "react"; +import { DropdownIcon } from "~/assets/icons/DropdownIcon"; +import { useEnvironmentSwitcher } from "~/hooks/useEnvironmentSwitcher"; +import { useFeatures } from "~/hooks/useFeatures"; +import { type MatchedOrganization } from "~/hooks/useOrganizations"; +import { cn } from "~/utils/cn"; +import { v3BillingPath } from "~/utils/pathBuilder"; +import { useDevPresence } from "../DevPresence"; +import { environmentTextClassName } from "../environments/EnvironmentLabel"; +import { + Popover, + PopoverContent, + PopoverSectionHeader, + PopoverTrigger, +} from "../primitives/Popover"; +import { SimpleTooltip } from "../primitives/Tooltip"; +import { BranchesPopoverContent, EnvironmentSelector } from "./EnvironmentSelector"; +import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu"; + +type EnvType = "DEVELOPMENT" | "STAGING" | "PREVIEW" | "PRODUCTION"; + +const SEGMENTS: { type: EnvType; label: string }[] = [ + { type: "DEVELOPMENT", label: "Dev" }, + { type: "STAGING", label: "Staging" }, + { type: "PREVIEW", label: "Prev" }, + { type: "PRODUCTION", label: "Prod" }, +]; + +// Upgrade copy mirrors the dropdown's "Additional environments" prompts. +const UPGRADE_MESSAGE: Partial> = { + STAGING: "Upgrade to unlock a Staging environment for your projects.", + PREVIEW: "Upgrade to unlock Preview environments for your projects.", +}; + +const PILL_LAYOUT_ID = "env-segmented-pill"; + +// The sliding selected pill is tinted with the env's own color. +const ENV_PILL: Record = { + DEVELOPMENT: "border-dev/30 bg-dev/15", + STAGING: "border-staging/30 bg-staging/15", + PREVIEW: "border-preview/30 bg-preview/15", + PRODUCTION: "border-prod/30 bg-prod/15", +}; + +const SEGMENT_CLASS = + "group relative flex h-full grow items-center justify-center gap-1 rounded px-1.5 text-xs font-medium outline-none focus-custom"; + +/** + * Side-menu-only environment switcher rendered as a segmented control (Dev / + * Staging / Prev / Prod) instead of the dropdown `EnvironmentSelector`. The + * dropdown is still used on blank-state panels and the Limits page. + */ +export function EnvironmentSegmentedControl({ + organization, + project, + environment, + isCollapsed = false, +}: { + organization: MatchedOrganization; + project: SideMenuProject; + environment: SideMenuEnvironment; + isCollapsed?: boolean; +}) { + const { urlForEnvironment } = useEnvironmentSwitcher(); + const { isManagedCloud } = useFeatures(); + const { isConnected } = useDevPresence(); + const navigation = useNavigation(); + const [isPreviewOpen, setIsPreviewOpen] = useState(false); + + useEffect(() => { + setIsPreviewOpen(false); + }, [navigation.location?.pathname]); + + const rootEnvFor = (type: EnvType) => + project.environments.find((e) => e.type === type && e.parentEnvironmentId === null); + + // Which segment is active. A branch (e.g. a preview branch) resolves to its + // parent env's type, so the parent segment shows as active. + const activeType: EnvType = (() => { + if (environment.parentEnvironmentId) { + const parent = project.environments.find((e) => e.id === environment.parentEnvironmentId); + if (parent) return parent.type as EnvType; + } + return environment.type as EnvType; + })(); + + // While the Preview popover is open, show Preview as the selected segment even + // if we haven't navigated into a preview branch yet. + const selectedType: EnvType = isPreviewOpen ? "PREVIEW" : activeType; + + // The dev connection is only tracked while you're in the dev environment (the + // presence provider is enabled there), matching the old connection button. + const showDevDot = environment.type === "DEVELOPMENT" && project.engine === "V2"; + + // When collapsed there's no room for the segmented control, so fall back to the + // original dropdown selector (env icon + menu). + if (isCollapsed) { + return ( + + ); + } + + return ( +
+ {SEGMENTS.map((segment) => { + const env = rootEnvFor(segment.type); + const isSelected = selectedType === segment.type; + const colorClass = isSelected + ? environmentTextClassName({ type: segment.type }) + : "text-text-dimmed transition group-hover:text-text-bright"; + + const pill = isSelected ? ( + + ) : null; + + // Preview: opens the branch switcher popover on click (active or not). + if (segment.type === "PREVIEW" && env) { + const branchEnvironments = project.environments.filter( + (e) => e.parentEnvironmentId === env.id + ); + // When the active environment is a preview branch, show the branch name + // truncated to 4 characters (plus an ellipsis) instead of "Prev". + const branchName = + environment.parentEnvironmentId === env.id ? environment.branchName : null; + const previewLabel = + branchName && branchName.length > 4 + ? `${branchName.slice(0, 4)}…` + : (branchName ?? segment.label); + return ( + + + {pill} + {previewLabel} + + + + + + + + ); + } + + // Missing env (Staging/Preview not provisioned): upgrade link on managed + // cloud, otherwise a disabled segment. + if (!env) { + const upgradeMessage = UPGRADE_MESSAGE[segment.type]; + if (isManagedCloud && upgradeMessage) { + return ( + + + {segment.label} + + + ); + } + return ( +
+ {segment.label} +
+ ); + } + + // Dev / Staging / Prod: navigate to the environment. + return ( + + {pill} + {segment.label} + {segment.type === "DEVELOPMENT" && showDevDot ? ( + + ) : null} + + ); + })} +
+ ); +} + +/** + * Small status dot (top-right of the Dev segment) replacing the old connection + * button: a pulsing green dot when connected, a static grey dot otherwise. Hover + * (after a short delay) reveals the connection status as a tooltip. + */ +function DevConnectionDot({ isConnected }: { isConnected: boolean | undefined }) { + const content = + isConnected === undefined + ? "Checking connection…" + : isConnected + ? "Your dev server is connected" + : "Your dev server is not connected"; + + return ( + + {isConnected ? ( + + ) : null} + + + } + /> + ); +} diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index 6701c072131..e6e74253955 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -41,16 +41,12 @@ 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); @@ -75,21 +71,6 @@ 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. - - - - - )} (null); @@ -251,23 +228,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}>
@@ -293,88 +253,134 @@ function Branches({ onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} > -
- {currentBranchIsArchived && ( - + + +
+ + ); +} + +/** + * The inner content of the branches popover (branch list, empty states, and the + * "Manage branches" footer). Shared by the dropdown's hover submenu (`Branches`) + * and the side-menu segmented control's 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"; + + // 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 ( + <> +
+ {currentBranchIsArchived && ( + + + {environment.branchName} + + Archived + + } + icon={ + + } + isSelected={environment.id === currentEnvironment.id} + /> + )} + {state === "has-branches" ? ( + <> + {branchEnvironments + .filter((env) => env.archivedAt === null) + .map((env) => ( + - {environment.branchName} + {env.branchName ?? DEFAULT_DEV_BRANCH} - Archived - - } - 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} + } + icon={ + - ))} - - ) : 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. -
- )} + } + 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. +
-
- {parentEnvironment.type === "DEVELOPMENT" ? ( - } - leadingIconClassName="text-text-dimmed" - /> - ) : ( - } - leadingIconClassName="text-text-dimmed" - /> - )} + ) : ( +
+ All branches are archived.
- + )}
- +
+ {parentEnvironment.type === "DEVELOPMENT" ? ( + } + leadingIconClassName="text-text-dimmed" + /> + ) : ( + } + leadingIconClassName="text-text-dimmed" + /> + )} +
+ ); } diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 4ea1178c6da..40ab558f765 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -110,10 +110,8 @@ import { import { AlphaBadge, NewBadge } from "../FeatureBadges"; import { AskAI } from "../AskAI"; import { FreePlanUsage } from "../billing/FreePlanUsage"; -import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence"; import { ImpersonationBanner } from "../ImpersonationBanner"; -import { Button, ButtonContent, LinkButton } from "../primitives/Buttons"; -import { Dialog, DialogTrigger } from "../primitives/Dialog"; +import { ButtonContent, LinkButton } from "../primitives/Buttons"; import { Paragraph } from "../primitives/Paragraph"; import { Badge } from "../primitives/Badge"; import { Popover, PopoverContent, PopoverMenuItem, PopoverTrigger } from "../primitives/Popover"; @@ -128,7 +126,7 @@ import { import { ShortcutsAutoOpen } from "../Shortcuts"; import { CreateDashboardButton } from "./DashboardDialogs"; import { DashboardList } from "./DashboardList"; -import { EnvironmentSelector } from "./EnvironmentSelector"; +import { EnvironmentSegmentedControl } from "./EnvironmentSegmentedControl"; import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; @@ -182,8 +180,6 @@ export function SideMenu({ organization, organizations, }: SideMenuProps) { - const borderRef = useRef(null); - const [showHeaderDivider, setShowHeaderDivider] = useState(false); const [isCollapsed, setIsCollapsed] = useState( user.dashboardPreferences.sideMenu?.isCollapsed ?? false ); @@ -195,7 +191,6 @@ export function SideMenu({ }>({}); const debounceTimeoutRef = useRef(null); const currentPlan = useCurrentPlan(); - const { isConnected } = useDevPresence(); const isFreeUser = currentPlan?.v3Subscription?.isPaying === false; const isAdmin = useHasAdminAccess(); const { isManagedCloud } = useFeatures(); @@ -292,20 +287,6 @@ 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]); - return (
) : null}
-
+
-
+
-
- - {environment.type === "DEVELOPMENT" && project.engine === "V2" && ( - - - - - -
- -
-
- - {isConnected === undefined - ? "Checking connection…" - : isConnected - ? "Your dev server is connected" - : "Your dev server is not connected"} - -
-
- -
-
- )} -
+
@@ -1049,7 +991,9 @@ function ProjectSelector({ From f2a3cbe234e17de85bc7c09cf1df404f7638215e Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 2 Jul 2026 10:03:31 +0100 Subject: [PATCH 07/16] feat(webapp): refine side menu selectors and spacing - Revert the environment segmented control back to the dropdown selector, restoring the dev connection button and its modal - Hide the org, project, and environment selector chevrons until hover - Reorganize the project popover so New project and Project settings sit above a divider, before the project list - Remove the Environments header from the environment popover - Fix the dev connection button being clipped and tune the scroll section padding --- .../EnvironmentSegmentedControl.tsx | 241 ------------------ .../navigation/EnvironmentSelector.tsx | 3 +- .../app/components/navigation/SideMenu.tsx | 89 +++++-- 3 files changed, 69 insertions(+), 264 deletions(-) delete mode 100644 apps/webapp/app/components/navigation/EnvironmentSegmentedControl.tsx diff --git a/apps/webapp/app/components/navigation/EnvironmentSegmentedControl.tsx b/apps/webapp/app/components/navigation/EnvironmentSegmentedControl.tsx deleted file mode 100644 index f6d82b5225e..00000000000 --- a/apps/webapp/app/components/navigation/EnvironmentSegmentedControl.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import { Link, useNavigation } from "@remix-run/react"; -import { motion } from "framer-motion"; -import { useEffect, useState } from "react"; -import { DropdownIcon } from "~/assets/icons/DropdownIcon"; -import { useEnvironmentSwitcher } from "~/hooks/useEnvironmentSwitcher"; -import { useFeatures } from "~/hooks/useFeatures"; -import { type MatchedOrganization } from "~/hooks/useOrganizations"; -import { cn } from "~/utils/cn"; -import { v3BillingPath } from "~/utils/pathBuilder"; -import { useDevPresence } from "../DevPresence"; -import { environmentTextClassName } from "../environments/EnvironmentLabel"; -import { - Popover, - PopoverContent, - PopoverSectionHeader, - PopoverTrigger, -} from "../primitives/Popover"; -import { SimpleTooltip } from "../primitives/Tooltip"; -import { BranchesPopoverContent, EnvironmentSelector } from "./EnvironmentSelector"; -import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu"; - -type EnvType = "DEVELOPMENT" | "STAGING" | "PREVIEW" | "PRODUCTION"; - -const SEGMENTS: { type: EnvType; label: string }[] = [ - { type: "DEVELOPMENT", label: "Dev" }, - { type: "STAGING", label: "Staging" }, - { type: "PREVIEW", label: "Prev" }, - { type: "PRODUCTION", label: "Prod" }, -]; - -// Upgrade copy mirrors the dropdown's "Additional environments" prompts. -const UPGRADE_MESSAGE: Partial> = { - STAGING: "Upgrade to unlock a Staging environment for your projects.", - PREVIEW: "Upgrade to unlock Preview environments for your projects.", -}; - -const PILL_LAYOUT_ID = "env-segmented-pill"; - -// The sliding selected pill is tinted with the env's own color. -const ENV_PILL: Record = { - DEVELOPMENT: "border-dev/30 bg-dev/15", - STAGING: "border-staging/30 bg-staging/15", - PREVIEW: "border-preview/30 bg-preview/15", - PRODUCTION: "border-prod/30 bg-prod/15", -}; - -const SEGMENT_CLASS = - "group relative flex h-full grow items-center justify-center gap-1 rounded px-1.5 text-xs font-medium outline-none focus-custom"; - -/** - * Side-menu-only environment switcher rendered as a segmented control (Dev / - * Staging / Prev / Prod) instead of the dropdown `EnvironmentSelector`. The - * dropdown is still used on blank-state panels and the Limits page. - */ -export function EnvironmentSegmentedControl({ - organization, - project, - environment, - isCollapsed = false, -}: { - organization: MatchedOrganization; - project: SideMenuProject; - environment: SideMenuEnvironment; - isCollapsed?: boolean; -}) { - const { urlForEnvironment } = useEnvironmentSwitcher(); - const { isManagedCloud } = useFeatures(); - const { isConnected } = useDevPresence(); - const navigation = useNavigation(); - const [isPreviewOpen, setIsPreviewOpen] = useState(false); - - useEffect(() => { - setIsPreviewOpen(false); - }, [navigation.location?.pathname]); - - const rootEnvFor = (type: EnvType) => - project.environments.find((e) => e.type === type && e.parentEnvironmentId === null); - - // Which segment is active. A branch (e.g. a preview branch) resolves to its - // parent env's type, so the parent segment shows as active. - const activeType: EnvType = (() => { - if (environment.parentEnvironmentId) { - const parent = project.environments.find((e) => e.id === environment.parentEnvironmentId); - if (parent) return parent.type as EnvType; - } - return environment.type as EnvType; - })(); - - // While the Preview popover is open, show Preview as the selected segment even - // if we haven't navigated into a preview branch yet. - const selectedType: EnvType = isPreviewOpen ? "PREVIEW" : activeType; - - // The dev connection is only tracked while you're in the dev environment (the - // presence provider is enabled there), matching the old connection button. - const showDevDot = environment.type === "DEVELOPMENT" && project.engine === "V2"; - - // When collapsed there's no room for the segmented control, so fall back to the - // original dropdown selector (env icon + menu). - if (isCollapsed) { - return ( - - ); - } - - return ( -
- {SEGMENTS.map((segment) => { - const env = rootEnvFor(segment.type); - const isSelected = selectedType === segment.type; - const colorClass = isSelected - ? environmentTextClassName({ type: segment.type }) - : "text-text-dimmed transition group-hover:text-text-bright"; - - const pill = isSelected ? ( - - ) : null; - - // Preview: opens the branch switcher popover on click (active or not). - if (segment.type === "PREVIEW" && env) { - const branchEnvironments = project.environments.filter( - (e) => e.parentEnvironmentId === env.id - ); - // When the active environment is a preview branch, show the branch name - // truncated to 4 characters (plus an ellipsis) instead of "Prev". - const branchName = - environment.parentEnvironmentId === env.id ? environment.branchName : null; - const previewLabel = - branchName && branchName.length > 4 - ? `${branchName.slice(0, 4)}…` - : (branchName ?? segment.label); - return ( - - - {pill} - {previewLabel} - - - - - - - - ); - } - - // Missing env (Staging/Preview not provisioned): upgrade link on managed - // cloud, otherwise a disabled segment. - if (!env) { - const upgradeMessage = UPGRADE_MESSAGE[segment.type]; - if (isManagedCloud && upgradeMessage) { - return ( - - - {segment.label} - - - ); - } - return ( -
- {segment.label} -
- ); - } - - // Dev / Staging / Prod: navigate to the environment. - return ( - - {pill} - {segment.label} - {segment.type === "DEVELOPMENT" && showDevDot ? ( - - ) : null} - - ); - })} -
- ); -} - -/** - * Small status dot (top-right of the Dev segment) replacing the old connection - * button: a pulsing green dot when connected, a static grey dot otherwise. Hover - * (after a short delay) reveals the connection status as a tooltip. - */ -function DevConnectionDot({ isConnected }: { isConnected: boolean | undefined }) { - const content = - isConnected === undefined - ? "Checking connection…" - : isConnected - ? "Your dev server is connected" - : "Your dev server is not connected"; - - return ( - - {isConnected ? ( - - ) : null} - - - } - /> - ); -} diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index e6e74253955..e26facf1c48 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -89,7 +89,7 @@ export function EnvironmentSelector({ @@ -111,7 +111,6 @@ 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 40ab558f765..469ba7abb5e 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -110,8 +110,10 @@ import { import { AlphaBadge, NewBadge } from "../FeatureBadges"; import { AskAI } from "../AskAI"; import { FreePlanUsage } from "../billing/FreePlanUsage"; +import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence"; import { ImpersonationBanner } from "../ImpersonationBanner"; -import { ButtonContent, LinkButton } from "../primitives/Buttons"; +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"; @@ -126,7 +128,7 @@ import { import { ShortcutsAutoOpen } from "../Shortcuts"; import { CreateDashboardButton } from "./DashboardDialogs"; import { DashboardList } from "./DashboardList"; -import { EnvironmentSegmentedControl } from "./EnvironmentSegmentedControl"; +import { EnvironmentSelector } from "./EnvironmentSelector"; import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; @@ -191,6 +193,7 @@ export function SideMenu({ }>({}); const debounceTimeoutRef = useRef(null); const currentPlan = useCurrentPlan(); + const { isConnected } = useDevPresence(); const isFreeUser = currentPlan?.v3Subscription?.isPaying === false; const isAdmin = useHasAdminAccess(); const { isManagedCloud } = useFeatures(); @@ -329,34 +332,67 @@ export function SideMenu({ ) : null}
-
+
- +
+ + {environment.type === "DEVELOPMENT" && project.engine === "V2" && ( + + + + + +
+ +
+
+ + {isConnected === undefined + ? "Checking connection…" + : isConnected + ? "Your dev server is connected" + : "Your dev server is not connected"} + +
+
+ +
+
+ )} +
-
+
@@ -969,11 +1005,13 @@ function OrgSelector({ function ProjectSelector({ project, organization, + environment, isCollapsed = false, className, }: { project: SideMenuProject; organization: MatchedOrganization; + environment: SideMenuEnvironment; isCollapsed?: boolean; className?: string; }) { @@ -1013,7 +1051,7 @@ function ProjectSelector({ @@ -1036,6 +1074,22 @@ function ProjectSelector({ style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }} >
+ + +
+
{organization.projects.map((p) => { const isSelected = p.id === project.id; return ( @@ -1054,13 +1108,6 @@ function ProjectSelector({ /> ); })} -
From 8e37da9b5cf7d430278590d3d89b9778da451666 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 2 Jul 2026 10:56:03 +0100 Subject: [PATCH 08/16] feat(webapp): polish the project selector, help menu, and collapsed side menu - Brighten the project selector trigger label and icon - Keep a constant 1px border on the project selector (transparent when collapsed) so it no longer nudges 1px on collapse - Animate the scroll section left padding in sync with the side menu collapse, matching its duration and easing - Remove the H shortcut key from the Help & Feedback button and surface it in a 500ms hover tooltip instead, keeping the shortcut working - Match the Help & Feedback label to the other side menu items (size and dimmed color) --- .../navigation/HelpAndFeedbackPopover.tsx | 12 ++---------- .../app/components/navigation/SideMenu.tsx | 17 +++++++++++------ 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx index 2eaa85dd81b..bbd5c3ea929 100644 --- a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx +++ b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx @@ -70,21 +70,13 @@ export function HelpAndFeedback({ Help & Feedback
- } content={ @@ -95,7 +87,7 @@ export function HelpAndFeedback({ } side="right" sideOffset={8} - hidden={!isCollapsed} + delayDuration={isCollapsed ? 0 : 500} buttonClassName="!h-8 w-full" asChild disableHoverableContent diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 469ba7abb5e..44dfa840d76 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -392,7 +392,12 @@ export function SideMenu({ : "scrollbar-gutter-stable scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600" )} > -
+
- + - + {project.name ?? "Select a project"} From 2fe465cc83493acc5bb3ddef3dedbd506445479d Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 2 Jul 2026 14:07:18 +0100 Subject: [PATCH 09/16] feat(webapp): align the project section padding with the nav items - Apply the scrollable section left padding to the top Project section (Project header, project selector, environment selector), animated in sync with the collapse - Keep the menus reducing width instead of shifting right by holding their right edge - Match the section right and bottom padding to the left padding for symmetry, since there is no scrollbar in this section - Leave the organization menu unchanged --- apps/webapp/app/components/navigation/SideMenu.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 44dfa840d76..b9e4339dd21 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -332,7 +332,12 @@ export function SideMenu({ ) : null}
-
+
From f593730a3b8792f9a3f57e59d4d8174860652b18 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 2 Jul 2026 18:03:27 +0100 Subject: [PATCH 10/16] feat(webapp): add a collapse toggle button to the side menu footer - Replace the Ask AI button in the side menu footer with a collapse toggle that mirrors the middle handle (toggles the menu, Collapse/Expand tooltip with the cmd+b shortcut) - Add an animated LeftSideMenuIcon (scaleX transform so it animates on the compositor and never snaps) that animates on button hover, dimmed by default and bright on hover - Swap in a static LeftSideMenuCollapsedIcon when the menu is collapsed, and match the tooltip open delay to the Help and Feedback button - Add LeftSideMenuIcon to the storybook icons route Removes the Ask AI button and its cmd+i shortcut from the footer. --- .../icons/LeftSideMenuCollapsedIcon.tsx | 22 +++++++ .../app/assets/icons/LeftSideMenuIcon.tsx | 49 ++++++++++++++ .../app/components/navigation/SideMenu.tsx | 66 ++++++++++++++++++- .../app/routes/storybook.icons/route.tsx | 2 + 4 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 apps/webapp/app/assets/icons/LeftSideMenuCollapsedIcon.tsx create mode 100644 apps/webapp/app/assets/icons/LeftSideMenuIcon.tsx 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..7db45082eb3 --- /dev/null +++ b/apps/webapp/app/assets/icons/LeftSideMenuIcon.tsx @@ -0,0 +1,49 @@ +import { motion } from "framer-motion"; +import { useState } from "react"; + +export function LeftSideMenuIcon({ + className, + hovered: controlledHovered, +}: { + className?: string; + /** + * When provided, the shape animation is driven by this prop (e.g. from a + * parent button's hover). When omitted, the icon animates on 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) rather than the SVG `width` attribute: + framer snaps the first animation of an SVG geometry attribute after it + has been idle, whereas transforms animate reliably. Anchoring the origin + to the left edge collapses the panel from right to left. */} + + + ); +} diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index d4f124fdf9c..84c84a2b0bd 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -30,6 +30,8 @@ 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"; @@ -105,7 +107,6 @@ 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"; @@ -716,6 +717,7 @@ export function SideMenu({ isCollapsed={isCollapsed} organizationId={organization.id} projectId={project.id} + onToggleCollapsed={handleToggleCollapsed} /> {isFreeUser && ( @@ -1346,10 +1348,12 @@ function HelpAndAI({ isCollapsed, organizationId, projectId, + onToggleCollapsed, }: { isCollapsed: boolean; organizationId: string; projectId: string; + onToggleCollapsed: () => void; }) { return ( @@ -1365,12 +1369,70 @@ function HelpAndAI({ organizationId={organizationId} projectId={projectId} /> - +
); } +function CollapseMenuButton({ + isCollapsed, + onToggle, +}: { + isCollapsed: boolean; + onToggle: () => void; +}) { + const [isHovering, setIsHovering] = useState(false); + + return ( +
+ + + + setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + > + + + + + {isCollapsed ? "Expand" : "Collapse"} + + + + + + + +
+ ); +} + function AnimatedChevron({ isHovering, isCollapsed, diff --git a/apps/webapp/app/routes/storybook.icons/route.tsx b/apps/webapp/app/routes/storybook.icons/route.tsx index dee5aa3e25a..a465e05fb5a 100644 --- a/apps/webapp/app/routes/storybook.icons/route.tsx +++ b/apps/webapp/app/routes/storybook.icons/route.tsx @@ -81,6 +81,7 @@ import { KeyboardUpIcon } from "~/assets/icons/KeyboardUpIcon"; import { KeyboardWindowsIcon } from "~/assets/icons/KeyboardWindowsIcon"; import { KeyIcon } from "~/assets/icons/KeyIcon"; import { KeyValueIcon } from "~/assets/icons/KeyValueIcon"; +import { LeftSideMenuIcon } from "~/assets/icons/LeftSideMenuIcon"; import { ListBulletIcon } from "~/assets/icons/ListBulletIcon"; import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; import { LogsIcon } from "~/assets/icons/LogsIcon"; @@ -217,6 +218,7 @@ const icons: IconEntry[] = [ { name: "KeyboardWindowsIcon", render: simple(KeyboardWindowsIcon) }, { name: "KeyIcon", render: simple(KeyIcon) }, { name: "KeyValueIcon", render: simple(KeyValueIcon) }, + { name: "LeftSideMenuIcon", render: simple(LeftSideMenuIcon) }, { name: "ListBulletIcon", render: simple(ListBulletIcon) }, { name: "ListCheckedIcon", render: simple(ListCheckedIcon) }, { name: "LlamaIcon", render: simple(LlamaIcon) }, From d5c39f59970f30dbf2f64d22b300c0db6009bbc7 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 2 Jul 2026 18:18:17 +0100 Subject: [PATCH 11/16] feat(webapp): remove the middle collapse handle in favor of the footer button The side menu footer collapse button now handles toggling, so the middle-right handle (and its animated chevron) is redundant. The cmd+b shortcut is preserved. --- .../app/components/navigation/SideMenu.tsx | 127 ------------------ 1 file changed, 127 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 84c84a2b0bd..1938a6d169c 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -296,7 +296,6 @@ export function SideMenu({ isCollapsed ? "w-[2.75rem]" : "w-56" )} > -
@@ -1432,129 +1431,3 @@ function CollapseMenuButton({
); } - -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"} - - - - - - - -
- ); -} From 24557d8ff77d77f8ae69f64138f190ca620518a2 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 2 Jul 2026 18:27:07 +0100 Subject: [PATCH 12/16] fix(webapp): keep the project section bottom padding constant when collapsing The top section's bottom padding was pb-1 when collapsed and pb-2.5 when open, causing a vertical layout shift below the environment menu on toggle. Make it a constant pb-2.5 so only the horizontal padding animates. --- apps/webapp/app/components/navigation/SideMenu.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 1938a6d169c..0cc5700b64e 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -332,8 +332,8 @@ export function SideMenu({
From 0b0f66d2d65717b83676b2425eb11eb1c91c6fd7 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 3 Jul 2026 14:29:39 +0100 Subject: [PATCH 13/16] feat(webapp): add an account menu to the side menu header - Add an Account menu (user avatar button) to the top of the side menu, between the organization menu and where the impersonation button used to be - The menu links to Profile, Personal Access Tokens, and Security, plus Logout - For admins it adds an Admin dashboard / Stop impersonating item with the cmd+esc shortcut shown inline, replacing the old permanent admin/impersonation button - Move Logout out of the organization menu into the account menu, and add a divider above Switch organization --- .../app/components/navigation/SideMenu.tsx | 143 ++++++++++++++---- 1 file changed, 115 insertions(+), 28 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 0cc5700b64e..b2dce71bade 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -4,7 +4,7 @@ import { ExclamationTriangleIcon, } from "@heroicons/react/24/outline"; import { LinkIcon } from "@heroicons/react/24/solid"; -import { useFetcher, useNavigation } from "@remix-run/react"; +import { useFetcher, useNavigate, useNavigation, useSubmit } from "@remix-run/react"; import { LayoutGroup, motion } from "framer-motion"; import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; import { AIChatIcon } from "~/assets/icons/AIChatIcon"; @@ -37,17 +37,20 @@ 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"; @@ -67,6 +70,8 @@ import { IncidentStatusPanel, useIncidentStatus } from "~/routes/resources.incid import { cn } from "~/utils/cn"; import { accountPath, + accountSecurityPath, + personalAccessTokensPath, adminPath, branchesPath, concurrencyPath, @@ -110,7 +115,6 @@ import { 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"; @@ -305,30 +309,9 @@ export function SideMenu({ isCollapsed={isCollapsed} />
- {isAdmin && !user.isImpersonating ? ( - - - - - - - - Admin dashboard - - - - - ) : isAdmin && user.isImpersonating ? ( - - - - ) : null} + + +
)} +
+
{organizations.length > 1 ? ( ) : ( @@ -987,14 +972,116 @@ function OrgSelector({ /> )}
-
+ + + ); +} + +function AccountMenu({ isAdmin, isImpersonating }: { isAdmin: boolean; isImpersonating: boolean }) { + const [isOpen, setIsOpen] = useState(false); + const navigation = useNavigation(); + const navigate = useNavigate(); + const submit = useSubmit(); + + useEffect(() => { + setIsOpen(false); + }, [navigation.location?.pathname]); + + const stopImpersonating = () => + submit(null, { action: "/resources/impersonation", method: "delete" }); + + useShortcutKeys({ + shortcut: isAdmin + ? { modifiers: ["mod"], key: "esc", enabledOnInputElements: true } + : undefined, + action: () => { + if (isImpersonating) { + stopImpersonating(); + } else { + navigate(adminPath()); + } + }, + }); + + return ( + setIsOpen(open)} open={isOpen}> + + + + } + content="Account" + side="bottom" + sideOffset={8} + disableHoverableContent + /> + + {isAdmin && ( +
+ {isImpersonating ? ( + + Stop impersonating + + + + +
+ } + icon={UserCrossIcon} + onClick={stopImpersonating} + leadingIconClassName={cn(SIDE_MENU_POPOVER_ITEM_ICON, "text-amber-400")} + className={SIDE_MENU_POPOVER_ITEM_LABEL} + /> + ) : ( + + Admin dashboard + + + + +
+ } + icon={HomeIcon} + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} + /> + )} +
+ )} +
+ +
Date: Fri, 3 Jul 2026 14:30:32 +0100 Subject: [PATCH 14/16] feat(webapp): use a custom chain link icon for private connections Add a ChainLinkIcon and use it for the Private connections item in both the organization settings side menu and the organization dropdown, replacing the heroicons link icon. --- .../webapp/app/assets/icons/ChainLinkIcon.tsx | 27 +++++++++++++++++++ .../OrganizationSettingsSideMenu.tsx | 5 ++-- .../app/components/navigation/SideMenu.tsx | 4 +-- 3 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 apps/webapp/app/assets/icons/ChainLinkIcon.tsx 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/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index ada3aac5e5a..8e1abfa20f9 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"; @@ -130,7 +131,7 @@ export function OrganizationSettingsSideMenu({ {featureFlags.hasPrivateConnections && ( From 6d701ab609883b25d4cc9a43aeb3496229a491fa Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 3 Jul 2026 14:53:53 +0100 Subject: [PATCH 15/16] feat(webapp): use the custom avatar icon for the profile picture placeholder - Replace the heroicons UserCircleIcon fallback in UserProfilePhoto/UserAvatar with the custom AvatarCircleIcon, matching the account menu Profile item - Size the account button avatar to match the popover menu item icons (20px) --- apps/webapp/app/components/UserProfilePhoto.tsx | 4 ++-- apps/webapp/app/components/navigation/SideMenu.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/components/UserProfilePhoto.tsx b/apps/webapp/app/components/UserProfilePhoto.tsx index 99febd1c240..16134174337 100644 --- a/apps/webapp/app/components/UserProfilePhoto.tsx +++ b/apps/webapp/app/components/UserProfilePhoto.tsx @@ -1,4 +1,4 @@ -import { UserCircleIcon } from "@heroicons/react/24/solid"; +import { AvatarCircleIcon } from "~/assets/icons/AvatarCircleIcon"; import { useOptionalUser } from "~/hooks/useUser"; import { cn } from "~/utils/cn"; @@ -26,6 +26,6 @@ export function UserAvatar({ />
) : ( - + ); } diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 59fb4fc3f98..ce32b956aca 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -1008,7 +1008,7 @@ function AccountMenu({ isAdmin, isImpersonating }: { isAdmin: boolean; isImperso - + } content="Account" From 347fabeb94a6a6a4e38b3dbd6d6751695c3227ec Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 3 Jul 2026 15:37:36 +0100 Subject: [PATCH 16/16] feat(webapp): redesign the account profile page with a row-based layout Adopt the Security page's row-and-divider pattern on /account: each setting is a row with the title on the left and its control on the right. Profile picture, Full name, Email address, and a "Receive onboarding emails" toggle switch sit on equal-height rows, and the Update button is now a primary button. --- .../account-profile-page-layout.md | 11 ++ .../app/routes/account._index/route.tsx | 117 ++++++++++-------- 2 files changed, 75 insertions(+), 53 deletions(-) create mode 100644 .server-changes/account-profile-page-layout.md diff --git a/.server-changes/account-profile-page-layout.md b/.server-changes/account-profile-page-layout.md new file mode 100644 index 00000000000..8c3c32c1772 --- /dev/null +++ b/.server-changes/account-profile-page-layout.md @@ -0,0 +1,11 @@ +--- +area: webapp +type: improvement +--- + +Redesign the account Profile page (`/account`) to use the same row-and-divider +layout as the Security page: each setting is a full-width row with the title on +the left and its control on the right, separated by divider lines. Profile +picture, Full name, Email address, and a "Receive onboarding emails" toggle +(replacing the old checkbox) each sit on equal-height rows, and the Update +button is now a primary button. diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index b4b92c8a133..fa24fa31d7c 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -3,8 +3,6 @@ import { conformZodMessage, parseWithZod } from "@conform-to/zod"; import { Form, type MetaFunction, useActionData } from "@remix-run/react"; import { type ActionFunction, json } from "@remix-run/server-runtime"; import { z } from "zod"; -import { AvatarCircleIcon } from "~/assets/icons/AvatarCircleIcon"; -import { EnvelopeIcon } from "~/assets/icons/EnvelopeIcon"; import { UserProfilePhoto } from "~/components/UserProfilePhoto"; import { MainHorizontallyCenteredContainer, @@ -12,15 +10,12 @@ import { PageContainer, } from "~/components/layout/AppLayout"; import { Button } from "~/components/primitives/Buttons"; -import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; -import { Fieldset } from "~/components/primitives/Fieldset"; -import { FormButtons } from "~/components/primitives/FormButtons"; import { FormError } from "~/components/primitives/FormError"; import { Header2 } from "~/components/primitives/Headers"; -import { Hint } from "~/components/primitives/Hint"; import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; +import { Switch } from "~/components/primitives/Switch"; import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; import { prisma } from "~/db.server"; import { useUser } from "~/hooks/useUser"; @@ -144,56 +139,72 @@ export default function Page() { - -
+ +
Profile
- - - - -
- - - - Your teammates will see this - {name.errors} - - - - - {email.errors} - - - - - {marketingEmails.errors} - - - - Update - - } - /> -
+
+
+ + + +
+ +
+
+
+
+
+ + + +
+ + {name.errors} +
+
+
+
+
+ + + +
+ + {email.errors} +
+
+
+
+
+ + + +
+ +
+
+
+
+ +