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/.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/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..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 (
+
+ );
+}
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/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx
index 58c3aae5a0a..a021c5b9895 100644
--- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx
+++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx
@@ -87,17 +87,17 @@ 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
@@ -190,10 +190,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);
@@ -225,23 +221,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}>
@@ -267,88 +246,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 (
+ <>
+
-
- 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.
-
-
+
+ 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.
+