diff --git a/.server-changes/side-menu-project-and-org-menus.md b/.server-changes/side-menu-project-and-org-menus.md
new file mode 100644
index 00000000000..d073d58ccc3
--- /dev/null
+++ b/.server-changes/side-menu-project-and-org-menus.md
@@ -0,0 +1,41 @@
+---
+area: webapp
+type: improvement
+---
+
+Refresh the side menu and account UI:
+
+- Add a new "Project" section above the "Environment" section with a popover
+ that lists the org's projects (folder icon + checkmark for the selected one)
+ and a "New project" item at the bottom.
+- The top-left menu now shows the organization (avatar + org name, no
+ project/diagonal divider) and its popover is a clean list of org-level items
+ (Settings, Usage, Billing with plan badge, Billing alerts, Team, Private
+ connections, Roles, SSO, Vercel integration, Slack integration, Switch
+ organization), with a separate account menu button (Profile, Personal Access
+ Tokens, Security, admin/impersonation, Logout) beside it.
+- Match the Environment selector popover's item sizing (icons and labels,
+ including the branch submenu and its footer) to the Project popover so the two
+ side-menu menus are visually consistent.
+- Redesign the account Profile page (/account) into the Security page's
+ row-and-divider layout: Profile picture, Full name, Email address, and a
+ "Receive onboarding emails" toggle on equal-height rows, with a primary Update
+ button.
+- Signal impersonation mode with a yellow side-menu border and a matching
+ "Stop impersonating" accent.
+- Move the Settings item to the top of the organization settings side menu.
+- Align the organization settings and account side menus' horizontal padding
+ with the main side menu, and tighten the "Personal Access Tokens" label so it
+ no longer truncates.
+- Restyle the "Shortcuts" and "Contact us…" entries in the Help & Feedback
+ popover to match the other menu items (icon size/alignment, dimmed text, text
+ size).
+- Make the main side menu resizable: drag its right edge to set a custom width
+ (remembered per user), with the labels, headers, and padding transitioning in
+ real time and a snap to open or collapsed when released below the default
+ width. Clicking the edge toggles the menu and a tooltip explains both
+ gestures.
+
+The org loader now exposes whether the RBAC and SSO plugins are installed so the
+side menu can gate the Roles and SSO items the same way the settings side menu
+does.
diff --git a/apps/webapp/app/assets/icons/AvatarCircleIcon.tsx b/apps/webapp/app/assets/icons/AvatarCircleIcon.tsx
index 83e67c8468c..30240b51c9a 100644
--- a/apps/webapp/app/assets/icons/AvatarCircleIcon.tsx
+++ b/apps/webapp/app/assets/icons/AvatarCircleIcon.tsx
@@ -1,4 +1,4 @@
-export function AvatarCircleIcon({ className }: { className?: string }) {
+function AvatarCircle({ className, strokeWidth }: { className?: string; strokeWidth: number }) {
return (
);
}
+
+/** User avatar placeholder with a 2px stroke (the default). */
+export function AvatarCircleIcon({ className }: { className?: string }) {
+ return ;
+}
+
+/** Thinner 1.5px-stroke variant of {@link AvatarCircleIcon}. */
+export function AvatarCircleIconThin({ className }: { className?: string }) {
+ return ;
+}
+
+/** Thinnest 1.25px-stroke variant of {@link AvatarCircleIcon}. */
+export function AvatarCircleIconExtraThin({ className }: { className?: string }) {
+ return ;
+}
diff --git a/apps/webapp/app/assets/icons/ChainLinkIcon.tsx b/apps/webapp/app/assets/icons/ChainLinkIcon.tsx
new file mode 100644
index 00000000000..f2e00245479
--- /dev/null
+++ b/apps/webapp/app/assets/icons/ChainLinkIcon.tsx
@@ -0,0 +1,27 @@
+export function ChainLinkIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/apps/webapp/app/assets/icons/LeftSideMenuCollapsedIcon.tsx b/apps/webapp/app/assets/icons/LeftSideMenuCollapsedIcon.tsx
new file mode 100644
index 00000000000..aa229af92a4
--- /dev/null
+++ b/apps/webapp/app/assets/icons/LeftSideMenuCollapsedIcon.tsx
@@ -0,0 +1,22 @@
+export function LeftSideMenuCollapsedIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/apps/webapp/app/assets/icons/LeftSideMenuIcon.tsx b/apps/webapp/app/assets/icons/LeftSideMenuIcon.tsx
new file mode 100644
index 00000000000..759ff962ba4
--- /dev/null
+++ b/apps/webapp/app/assets/icons/LeftSideMenuIcon.tsx
@@ -0,0 +1,44 @@
+import { motion } from "framer-motion";
+import { useState } from "react";
+
+export function LeftSideMenuIcon({
+ className,
+ hovered: controlledHovered,
+}: {
+ className?: string;
+ /** Drives the animation when provided (e.g. parent hover); otherwise the icon uses its own hover. */
+ hovered?: boolean;
+}) {
+ const [internalHovered, setInternalHovered] = useState(false);
+ const isControlled = controlledHovered !== undefined;
+ const hovered = isControlled ? controlledHovered : internalHovered;
+
+ return (
+
+ );
+}
diff --git a/apps/webapp/app/components/AskAI.tsx b/apps/webapp/app/components/AskAI.tsx
index d62ffa5b33d..0d32265c251 100644
--- a/apps/webapp/app/components/AskAI.tsx
+++ b/apps/webapp/app/components/AskAI.tsx
@@ -11,11 +11,12 @@ import { useSearchParams } from "@remix-run/react";
import DOMPurify from "dompurify";
import { motion } from "framer-motion";
import { marked } from "marked";
-import { useCallback, useEffect, useRef, useState } from "react";
+import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { useTypedRouteLoaderData } from "remix-typedjson";
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
import { SparkleListIcon } from "~/assets/icons/SparkleListIcon";
import { useFeatures } from "~/hooks/useFeatures";
+import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { type loader } from "~/root";
import { Button } from "./primitives/Buttons";
import { Callout } from "./primitives/Callout";
@@ -38,6 +39,104 @@ function useKapaWebsiteId() {
return routeMatch?.kapa.websiteId;
}
+/** Open/close state for the Ask AI dialog, including the `?aiHelp=` deep-link handling. */
+function useAskAIState() {
+ const [isOpen, setIsOpen] = useState(false);
+ const [initialQuery, setInitialQuery] = useState();
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const openAskAI = useCallback((question?: string) => {
+ if (question) {
+ setInitialQuery(question);
+ } else {
+ setInitialQuery(undefined);
+ }
+ setIsOpen(true);
+ }, []);
+
+ const closeAskAI = useCallback(() => {
+ setIsOpen(false);
+ setInitialQuery(undefined);
+ }, []);
+
+ // Handle URL param functionality
+ useEffect(() => {
+ const aiHelp = searchParams.get("aiHelp");
+ if (aiHelp) {
+ // Delay to avoid hCaptcha bot detection
+ window.setTimeout(() => openAskAI(aiHelp), 1000);
+
+ // Clone instead of mutating in place
+ const next = new URLSearchParams(searchParams);
+ next.delete("aiHelp");
+ setSearchParams(next);
+ }
+ }, [searchParams, openAskAI]);
+
+ return { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI };
+}
+
+/**
+ * Hosts Ask AI (Kapa provider, ⌘I shortcut, dialog) for a menu that renders its own trigger. Wrap
+ * it around the popover, not inside, so the dialog and shortcut survive the popover closing.
+ * `children` receives the open function, or undefined when Ask AI is unavailable (self-hosted, no
+ * Kapa website id, or SSR).
+ */
+export function AskAIRoot({
+ children,
+}: {
+ children: (openAskAI: (() => void) | undefined) => ReactNode;
+}) {
+ const { isManagedCloud } = useFeatures();
+ const websiteId = useKapaWebsiteId();
+
+ if (!isManagedCloud || !websiteId) {
+ return <>{children(undefined)}>;
+ }
+
+ return (
+ {children(undefined)}>}>
+ {() => {children}}
+
+ );
+}
+
+function AskAIRootProvider({
+ websiteId,
+ children,
+}: {
+ websiteId: string;
+ children: (openAskAI: () => void) => ReactNode;
+}) {
+ const { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI } = useAskAIState();
+
+ useShortcutKeys({
+ shortcut: { modifiers: ["mod"], key: "i", enabledOnInputElements: true },
+ action: () => openAskAI(),
+ });
+
+ return (
+ openAskAI(),
+ onAnswerGenerationCompleted: () => openAskAI(),
+ },
+ }}
+ botProtectionMechanism="hcaptcha"
+ >
+ {children(() => openAskAI())}
+
+
+ );
+}
+
export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) {
const { isManagedCloud } = useFeatures();
const websiteId = useKapaWebsiteId();
@@ -72,37 +171,7 @@ type AskAIProviderProps = {
};
function AskAIProvider({ websiteId, isCollapsed = false }: AskAIProviderProps) {
- const [isOpen, setIsOpen] = useState(false);
- const [initialQuery, setInitialQuery] = useState();
- const [searchParams, setSearchParams] = useSearchParams();
-
- const openAskAI = useCallback((question?: string) => {
- if (question) {
- setInitialQuery(question);
- } else {
- setInitialQuery(undefined);
- }
- setIsOpen(true);
- }, []);
-
- const closeAskAI = useCallback(() => {
- setIsOpen(false);
- setInitialQuery(undefined);
- }, []);
-
- // Handle URL param functionality
- useEffect(() => {
- const aiHelp = searchParams.get("aiHelp");
- if (aiHelp) {
- // Delay to avoid hCaptcha bot detection
- window.setTimeout(() => openAskAI(aiHelp), 1000);
-
- // Clone instead of mutating in place
- const next = new URLSearchParams(searchParams);
- next.delete("aiHelp");
- setSearchParams(next);
- }
- }, [searchParams, openAskAI]);
+ const { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI } = useAskAIState();
return (
{
+ if (!isAdmin) return;
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ // Admin escape hatch: Cmd+Option+A (Ctrl+Alt+A on Windows) opens the admin dashboard, or stops
+ // impersonating. Avoids Escape — Chrome/macOS never delivers a keydown for Escape+modifier (why
+ // the old Cmd+Esc did nothing). Matched on `event.code`, not `event.key`, because Option makes
+ // "A" report "å" (so a raw listener, not the `event.key`-based useShortcutKeys hook).
+ if (event.code !== "KeyA" || !event.altKey || !(event.metaKey || event.ctrlKey)) {
+ return;
+ }
+ event.preventDefault();
+ if (isImpersonating) {
+ submit(null, { action: "/resources/impersonation", method: "delete" });
+ } else {
+ navigate(adminPath());
+ }
+ };
+
+ document.addEventListener("keydown", onKeyDown);
+ return () => document.removeEventListener("keydown", onKeyDown);
+ }, [isAdmin, isImpersonating, navigate, submit]);
+
+ return null;
+}
diff --git a/apps/webapp/app/components/Shortcuts.tsx b/apps/webapp/app/components/Shortcuts.tsx
index 63b5fddf715..87e5d08f371 100644
--- a/apps/webapp/app/components/Shortcuts.tsx
+++ b/apps/webapp/app/components/Shortcuts.tsx
@@ -1,8 +1,8 @@
import { KeyboardIcon } from "~/assets/icons/KeyboardIcon";
import { useState } from "react";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
-import { Button } from "./primitives/Buttons";
import { Header3 } from "./primitives/Headers";
+import { SideMenuItemButton } from "./navigation/SideMenuItem";
import { Paragraph } from "./primitives/Paragraph";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "./primitives/SheetV3";
import { ShortcutKey } from "./primitives/ShortcutKey";
@@ -11,19 +11,12 @@ export function Shortcuts() {
return (
-
+ trailing={}
+ />
diff --git a/apps/webapp/app/components/UserProfilePhoto.tsx b/apps/webapp/app/components/UserProfilePhoto.tsx
index 4676594dbcc..92c435aa109 100644
--- a/apps/webapp/app/components/UserProfilePhoto.tsx
+++ b/apps/webapp/app/components/UserProfilePhoto.tsx
@@ -1,31 +1,62 @@
-import { UserCircleIcon } from "@heroicons/react/24/solid";
+import {
+ AvatarCircleIcon,
+ AvatarCircleIconExtraThin,
+ AvatarCircleIconThin,
+} from "~/assets/icons/AvatarCircleIcon";
import { useOptionalUser } from "~/hooks/useUser";
import { cn } from "~/utils/cn";
-export function UserProfilePhoto({ className }: { className?: string }) {
+/** Stroke width (px) of the placeholder avatar icon shown when there is no photo. */
+type AvatarStrokeWidth = 1.25 | 1.5 | 2;
+
+const PLACEHOLDER_BY_STROKE_WIDTH = {
+ 1.25: AvatarCircleIconExtraThin,
+ 1.5: AvatarCircleIconThin,
+ 2: AvatarCircleIcon,
+} as const;
+
+export function UserProfilePhoto({
+ className,
+ strokeWidth = 2,
+}: {
+ className?: string;
+ strokeWidth?: AvatarStrokeWidth;
+}) {
const user = useOptionalUser();
- return ;
+ return (
+
+ );
}
export function UserAvatar({
avatarUrl,
name,
className,
+ strokeWidth = 2,
}: {
avatarUrl?: string | null;
name?: string | null;
className?: string;
+ strokeWidth?: AvatarStrokeWidth;
}) {
- return avatarUrl ? (
-
-
-
- ) : (
-
- );
+ if (avatarUrl) {
+ return (
+
+
+
+ );
+ }
+
+ const PlaceholderIcon = PLACEHOLDER_BY_STROKE_WIDTH[strokeWidth];
+ return ;
}
diff --git a/apps/webapp/app/components/environments/EnvironmentLabel.tsx b/apps/webapp/app/components/environments/EnvironmentLabel.tsx
index 8143e5e5e7d..3fd5d526fc1 100644
--- a/apps/webapp/app/components/environments/EnvironmentLabel.tsx
+++ b/apps/webapp/app/components/environments/EnvironmentLabel.tsx
@@ -81,12 +81,15 @@ export function EnvironmentLabel({
tooltipSideOffset = 34,
tooltipSide = "right",
disableTooltip = false,
+ truncate = true,
}: {
environment: Environment;
className?: string;
tooltipSideOffset?: number;
tooltipSide?: "top" | "right" | "bottom" | "left";
disableTooltip?: boolean;
+ /** When false, the label clips without an ellipsis (side menu fades it in place). Defaults true. */
+ truncate?: boolean;
}) {
const spanRef = useRef(null);
const [isTruncated, setIsTruncated] = useState(false);
@@ -113,7 +116,12 @@ export function EnvironmentLabel({
const content = (
{text}
diff --git a/apps/webapp/app/components/navigation/AccountSideMenu.tsx b/apps/webapp/app/components/navigation/AccountSideMenu.tsx
index ba7938adde6..f48de39e2fc 100644
--- a/apps/webapp/app/components/navigation/AccountSideMenu.tsx
+++ b/apps/webapp/app/components/navigation/AccountSideMenu.tsx
@@ -7,7 +7,6 @@ import {
personalAccessTokensPath,
rootPath,
} from "~/utils/pathBuilder";
-import { AskAI } from "../AskAI";
import { LinkButton } from "../primitives/Buttons";
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
import { SideMenuHeader } from "./SideMenuHeader";
@@ -34,7 +33,7 @@ export function AccountSideMenu({ user }: { user: User }) {
Back to app
-
+
-
);
diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx
index 7e2a3efdb50..b3375681b59 100644
--- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx
+++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx
@@ -35,18 +35,26 @@ import { V4Badge } from "../V4Badge";
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
import { Badge } from "../primitives/Badge";
+// Size this Env popover's items to match the Project popover (SIDE_MENU_POPOVER_ITEM_* in
+// SideMenu.tsx). Only at these call sites, so shared EnvironmentLabel/EnvironmentCombo defaults stay.
+const ENV_POPOVER_ITEM_ICON = "size-5";
+const ENV_POPOVER_ITEM_LABEL = "text-[0.90625rem] font-medium tracking-[-0.01em]";
+
export function EnvironmentSelector({
organization,
project,
environment,
className,
isCollapsed = false,
+ isDragging = false,
}: {
organization: MatchedOrganization;
project: SideMenuProject;
environment: SideMenuEnvironment;
className?: string;
isCollapsed?: boolean;
+ /** True while the side menu is being drag-resized; keeps the row in its expanded arrangement. */
+ isDragging?: boolean;
}) {
const { isManagedCloud } = useFeatures();
const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -73,42 +81,56 @@ export function EnvironmentSelector({
button={
+ {/*
+ Opacity follows --sm-label-opacity to fade both directions without popping in on
+ drag-open; the generous max-width cap fades the text in place (not truncated) but
+ scales to 0 so it never holds width. Unset elsewhere → fully visible.
+ */}
+ {/*
+ Chevron's 16px width follows --sm-label-opacity so an invisible span never holds width
+ mid-drag and pushes the row's clip edge into the icon.
+ */}
-
+
}
- content={environmentFullTitle(environment)}
+ content={`${environmentFullTitle(environment)} environment`}
side="right"
sideOffset={8}
+ // Tooltip only on the collapsed rail (expanded shows the label; this selector is also reused
+ // outside the side menu, where a hover tooltip is unwanted).
hidden={!isCollapsed}
+ delayDuration={0}
buttonClassName="h-8!"
asChild
+ tabbable
disableHoverableContent
/>
}
+ title={
+
+ }
isSelected={env.id === environment.id}
/>
);
@@ -162,8 +190,12 @@ export function EnvironmentSelector({
)}
title={
-
- 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.
+
-
+ ) : (
+
+ All branches are archived.
+
+ )}
-
+ >
);
}
diff --git a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx
index cf3cb62aaa5..669271fe766 100644
--- a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx
+++ b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx
@@ -1,25 +1,27 @@
import { ArrowUpRightIcon } from "@heroicons/react/20/solid";
import { motion } from "framer-motion";
import { Fragment, useState } from "react";
+import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
import { BookIcon } from "~/assets/icons/BookIcon";
import { BulbIcon } from "~/assets/icons/BulbIcon";
+import { DropdownIcon } from "~/assets/icons/DropdownIcon";
import { EnvelopeIcon } from "~/assets/icons/EnvelopeIcon";
import { QuestionMarkIcon } from "~/assets/icons/QuestionMarkIcon";
import { RadarPulseIcon } from "~/assets/icons/RadarPulseIcon";
import { StarIcon } from "~/assets/icons/StarIcon";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
-import { sanitizeHttpUrl } from "~/utils/sanitizeUrl";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { useRecentChangelogs } from "~/routes/resources.platform-changelogs";
import { cn } from "~/utils/cn";
+import { sanitizeHttpUrl } from "~/utils/sanitizeUrl";
+import { AskAIRoot } from "../AskAI";
import { Feedback } from "../Feedback";
import { Shortcuts } from "../Shortcuts";
-import { Button } from "../primitives/Buttons";
import { Paragraph } from "../primitives/Paragraph";
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
import { ShortcutKey } from "../primitives/ShortcutKey";
import { SimpleTooltip } from "../primitives/Tooltip";
-import { SideMenuItem } from "./SideMenuItem";
+import { SideMenuItem, SideMenuItemButton } from "./SideMenuItem";
export function HelpAndFeedback({
disableShortcut = false,
@@ -49,135 +51,161 @@ export function HelpAndFeedback({
-
-
-
-
-
+ {(openAskAI) => (
+
+
+
+
+ {/*
+ Width + opacity follow --sm-label-opacity so the label tracks a drag both
+ directions (no CSS transition — it would lag the per-frame writes).
+ */}
+
+ Help & Feedback
+
+
+ {/*
+ Hover chevron, only when expanded. Its 16px width follows --sm-label-opacity so
+ an invisible chevron never holds width mid-drag and clips the help icon.
+ */}
+ {!isCollapsed && (
+
+
+
+ )}
+
+ }
+ content={
+
Help & Feedback
+
-
-
+
+
+ {openAskAI !== undefined && (
+
+ {/* Collapsed: the account button is hidden, so surface Account as a submenu here (the only
+ always-reachable menu on the rail). */}
+ {isCollapsed && (
+
+
);
}
-/** Helper component that fades out but preserves width (collapses to 0 width) */
+/**
+ * Fades out and collapses to 0 width via the menu's `--sm-label-opacity` variable, tracking a drag
+ * in real time (no CSS opacity transition — it would lag the per-frame variable writes).
+ */
function CollapsibleElement({
- isCollapsed,
+ isDragging = false,
children,
className,
}: {
- isCollapsed: boolean;
+ /** Only blocks clicks on the fading button mid-drag; the hiding is width+opacity below. */
+ isDragging?: boolean;
children: ReactNode;
className?: string;
}) {
+ // Width AND opacity follow --sm-label-opacity: opacity alone would leave the invisible button
+ // holding 32px of row width, pushing the primary item's clip edge into its icon ("masked" mid-drag).
+ // Shrinking width on the same curve hands that space back. No CSS transition (it would lag the writes).
return (
);
}
-function AnimatedChevron({
- isHovering,
+function CollapseMenuButton({
isCollapsed,
+ isDragging = false,
+ onToggle,
}: {
- isHovering: boolean;
isCollapsed: boolean;
+ isDragging?: boolean;
+ onToggle: () => void;
}) {
- // When hovering and expanded: left chevron (pointing left to collapse)
- // When hovering and collapsed: right chevron (pointing right to expand)
- // When not hovering: straight vertical line
-
- const getRotation = () => {
- if (!isHovering) return { top: 0, bottom: 0 };
- if (isCollapsed) {
- // Right chevron
- return { top: -17, bottom: 17 };
- } else {
- // Left chevron
- return { top: 17, bottom: -17 };
- }
- };
-
- const { top, bottom } = getRotation();
-
- // Calculate horizontal offset to keep chevron centered when rotated
- // Left chevron: translate left (-1.5px)
- // Right chevron: translate right (+1.5px)
- const getTranslateX = () => {
- if (!isHovering) return 0;
- return isCollapsed ? 1.5 : -1.5;
- };
-
- return (
-
- {/* Top segment */}
-
- {/* Bottom segment */}
-
-
- );
-}
-
-function CollapseToggle({ isCollapsed, onToggle }: { isCollapsed: boolean; onToggle: () => void }) {
const [isHovering, setIsHovering] = useState(false);
return (
-
- {/* Vertical line to mask the side menu border */}
-
+ // Shrink-and-fade only while dragging CLOSED, where this sits beside Help & Feedback and would
+ // overlap it as the row narrows. Dragging OPEN it stays put: collapsed, this IS the expand
+ // affordance, and the 0->1 variable would make the icon grow from nothing. At rest: natural size.
+
);
}
+
+/**
+ * Resize affordance straddling the menu's right border: hover reveals an indigo line, drag resizes,
+ * click toggles collapsed/expanded, and the tooltip follows the pointer's Y. The strip extends 4px
+ * past the edge, so the menu root deliberately has no overflow-hidden (only its inner grid does).
+ */
+function ResizeHandle({
+ isCollapsed,
+ isDragging,
+ onPointerDown,
+}: {
+ isCollapsed: boolean;
+ isDragging: boolean;
+ onPointerDown: (e: ReactPointerEvent) => void;
+}) {
+ // Fully controlled so open never flips controlled/uncontrolled mid-interaction; open requests
+ // during a drag are dropped.
+ const [isTooltipOpen, setTooltipOpen] = useState(false);
+ // Pointer Y within the strip — anchors the tooltip beside the cursor, not the strip's center.
+ const [anchorY, setAnchorY] = useState(0);
+
+ return (
+
+ setTooltipOpen(open && !isDragging)}
+ >
+
+
);
@@ -125,9 +134,11 @@ export function SideMenuItem({
buttonClassName="h-8! block w-full"
hidden={!isCollapsed}
asChild
+ tabbable
disableHoverableContent
/>
{!isCollapsed && (
+ // Fades with the labels via --sm-label-opacity (unset → fully visible).
{action}
@@ -152,7 +164,35 @@ export function SideMenuItem({
buttonClassName="h-8! block w-full"
hidden={!isCollapsed}
asChild
+ tabbable
disableHoverableContent
/>
);
}
+
+/** Button styled to match {@link SideMenuItem}, for entries that open a dialog rather than navigate. */
+export const SideMenuItemButton = forwardRef<
+ HTMLButtonElement,
+ { icon: RenderIcon; name: string; trailing?: ReactNode } & ButtonHTMLAttributes
+>(function SideMenuItemButton({ icon, name, trailing, className, type, ...props }, ref) {
+ return (
+
+ );
+});
diff --git a/apps/webapp/app/components/navigation/SideMenuSection.tsx b/apps/webapp/app/components/navigation/SideMenuSection.tsx
index 70b5e099538..8225d9e701c 100644
--- a/apps/webapp/app/components/navigation/SideMenuSection.tsx
+++ b/apps/webapp/app/components/navigation/SideMenuSection.tsx
@@ -1,5 +1,5 @@
import { AnimatePresence, motion } from "framer-motion";
-import React, { useCallback, useState } from "react";
+import React, { useCallback, useEffect, useRef, useState } from "react";
import { ToggleArrowIcon } from "~/assets/icons/ToggleArrowIcon";
type Props = {
@@ -14,9 +14,7 @@ type Props = {
headerAction?: React.ReactNode;
};
-/** A collapsible section for the side menu
- * The collapsed state is passed in as a prop, and there's a callback when it's toggled so we can save the state.
- */
+/** A collapsible section for the side menu. Collapsed state is controlled via props + a toggle callback. */
export function SideMenuSection({
title,
initialCollapsed = false,
@@ -27,6 +25,7 @@ export function SideMenuSection({
headerAction,
}: Props) {
const [isCollapsed, setIsCollapsed] = useState(initialCollapsed);
+ const contentRef = useRef(null);
const handleToggle = useCallback(() => {
const newIsCollapsed = !isCollapsed;
@@ -34,22 +33,37 @@ export function SideMenuSection({
onCollapseToggle?.(newIsCollapsed);
}, [isCollapsed, onCollapseToggle]);
+ // Collapsed items stay in the DOM (height 0) for the animation, so `inert` removes them from the
+ // tab order and a11y tree (it doesn't affect layout). Set the DOM property directly — React 18's
+ // `inert` prop handling is unreliable.
+ useEffect(() => {
+ if (contentRef.current) {
+ contentRef.current.inert = isCollapsed;
+ }
+ }, [isCollapsed]);
+
return (
{/* Header container - stays in DOM to preserve height */}
- {/* Header - fades out when sidebar is collapsed */}
-
-
+
{title}
{headerAction &&
{headerAction}
}
-
- {/* Divider - absolutely positioned, visible when sidebar is collapsed but section is expanded */}
-
+ {/*
+ Divider fades in via --sm-collapse (0 → 1) as the header fades out. Only while expanded.
+ */}
+
void;
@@ -88,7 +92,7 @@ function SimpleTooltip({
+
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx
index c7935544082..32920a678ff 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx
@@ -496,7 +496,8 @@ export default function Page() {
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx
index cfe74e9ccc9..26133675e0d 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 { canManageBillingLimits } 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,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
.then(({ regions }) => regions)
.catch(() => [] as Region[])
: Promise.resolve([] as Region[]),
+ // Resolve which optional plugins (RBAC, SSO) are installed so the side menu can gate their
+ // items. Both calls are cheap and cached.
+ rbac.isUsingPlugin().catch(() => false),
+ ssoController.isUsingPlugin().catch(() => false),
]);
const userCanManageBillingLimits = sessionAuth.ok
? canManageBillingLimits(sessionAuth.ability)
@@ -184,6 +218,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
},
widgetLimitPerDashboard,
canManageBillingLimits: userCanManageBillingLimits,
+ isUsingRbacPlugin,
+ isUsingSsoPlugin,
});
};
diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx
index b4b92c8a133..99ef9b87bb3 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
diff --git a/apps/webapp/app/routes/resources.preferences.sidemenu.tsx b/apps/webapp/app/routes/resources.preferences.sidemenu.tsx
index 94db448b581..4e43269d0ea 100644
--- a/apps/webapp/app/routes/resources.preferences.sidemenu.tsx
+++ b/apps/webapp/app/routes/resources.preferences.sidemenu.tsx
@@ -15,6 +15,7 @@ const booleanFromFormData = z
const RequestSchema = z.object({
isCollapsed: booleanFromFormData,
+ width: z.coerce.number().int().positive().optional(),
sectionId: SideMenuSectionIdSchema.optional(),
sectionCollapsed: booleanFromFormData,
// Generic item order fields
@@ -66,6 +67,7 @@ export async function action({ request }: ActionFunctionArgs) {
await updateSideMenuPreferences({
user,
isCollapsed: result.data.isCollapsed,
+ width: result.data.width,
sectionCollapsed,
});
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) },
diff --git a/apps/webapp/app/services/dashboardPreferences.server.ts b/apps/webapp/app/services/dashboardPreferences.server.ts
index 7af007dc381..a8b9149eff5 100644
--- a/apps/webapp/app/services/dashboardPreferences.server.ts
+++ b/apps/webapp/app/services/dashboardPreferences.server.ts
@@ -5,6 +5,8 @@ import { type UserFromSession } from "./session.server";
const SideMenuPreferences = z.object({
isCollapsed: z.boolean().default(false),
+ /** Expanded side menu width in px, set by the resize handle. */
+ width: z.number().optional(),
// Map for section collapsed states - keys are section identifiers
collapsedSections: z.record(z.string(), z.boolean()).optional(),
/** Organization-specific settings */
@@ -124,10 +126,13 @@ export async function clearCurrentProject({ user }: { user: UserFromSession }) {
export async function updateSideMenuPreferences({
user,
isCollapsed,
+ width,
sectionCollapsed,
}: {
user: UserFromSession;
isCollapsed?: boolean;
+ /** Expanded side menu width in px (from the resize handle) */
+ width?: number;
/** Update a specific section's collapsed state */
sectionCollapsed?: { sectionId: SideMenuSectionId; collapsed: boolean };
}) {
@@ -148,6 +153,7 @@ export async function updateSideMenuPreferences({
const updatedSideMenu = SideMenuPreferences.parse({
...currentSideMenu,
...(isCollapsed !== undefined && { isCollapsed }),
+ ...(width !== undefined && { width }),
collapsedSections: updatedCollapsedSections,
});
@@ -156,7 +162,11 @@ export async function updateSideMenuPreferences({
JSON.stringify(updatedSideMenu.collapsedSections) !==
JSON.stringify(currentSideMenu.collapsedSections);
- if (updatedSideMenu.isCollapsed === currentSideMenu.isCollapsed && !hasCollapsedSectionsChanged) {
+ if (
+ updatedSideMenu.isCollapsed === currentSideMenu.isCollapsed &&
+ updatedSideMenu.width === currentSideMenu.width &&
+ !hasCollapsedSectionsChanged
+ ) {
return;
}
diff --git a/apps/webapp/app/tailwind.css b/apps/webapp/app/tailwind.css
index f7b05430c89..e15c71b5f2c 100644
--- a/apps/webapp/app/tailwind.css
+++ b/apps/webapp/app/tailwind.css
@@ -342,8 +342,10 @@
@utility focus-custom {
&:focus-visible {
outline: 1px solid var(--color-text-link);
- outline-offset: 0px;
- border-radius: 3px;
+ /* Inset the ring inside the box; at offset 0 the outline sits outside the border edge and gets
+ clipped by ancestors with overflow hidden/auto (scroll areas, the side menu grid). */
+ outline-offset: -1px;
+ border-radius: 5px;
}
}
@@ -351,6 +353,44 @@
scrollbar-gutter: stable;
}
+/* Fade the thumb in only while the pointer is in the scroll area. Uses
+ ::-webkit-scrollbar (no scrollbar-width) so Chrome keeps a classic always-present
+ bar to reveal, not the auto-hiding overlay; Firefox falls back to standard props.
+ Thumb color is a registered @property so it can transition. */
+@property --sm-sb-thumb {
+ syntax: "";
+ initial-value: transparent;
+ inherits: true;
+}
+
+@utility scrollbar-thumb-on-hover {
+ transition: --sm-sb-thumb 200ms ease;
+
+ &::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+ }
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+ &::-webkit-scrollbar-thumb {
+ background-color: var(--sm-sb-thumb);
+ /* transparent border + padding-box clip = thinner thumb, inset from the channel edges */
+ border: 2px solid transparent;
+ border-radius: 9999px;
+ background-clip: padding-box;
+ }
+
+ @supports (-moz-appearance: none) {
+ scrollbar-width: thin;
+ scrollbar-color: var(--sm-sb-thumb) transparent;
+ }
+
+ &:hover {
+ --sm-sb-thumb: var(--color-surface-control);
+ }
+}
+
/* Shared stop list for the animated glow utilities below. The gradient itself
is assembled per-element so the animated --gradient-angle resolves there. */
:root {