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..0f51b225457 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,58 @@ export function EnvironmentSelector({
button={
+ {/*
+ In the side menu, opacity + max-width follow --sm-label-opacity (1 → 0): the label
+ fades in place and scales its width to 0 so it never holds width mid-drag. The
+ selector is also reused outside the side menu (BlankStatePanels, limits) where the var
+ is unset — the 0.2 max-width fallback pins a ~200px cap (0.2 * 1000px) so long names
+ ellipsis-truncate there instead of widening the control, while opacity stays 1.
+ */}
+ {/*
+ 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 +192,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 (
{children}
);
}
-/** Helper component that fades out and collapses height completely */
-function CollapsibleHeight({
- isCollapsed,
- children,
- className,
+/**
+ * The Free-plan banner at the foot of the menu. On close it doesn't collapse or slide on its own: its
+ * reserved height collapses (tracking --sm-collapse, gone by the halfway point) and, because the bottom
+ * section is pinned to the bottom, that pushes the whole section (Help & Feedback + this banner) down so
+ * the full-height banner slides off the bottom edge. On open it lags, waiting for the settled "shown"
+ * phase, then rises back up via translateY. Height is measured so the reclaimed space matches the banner.
+ */
+function FreePlanBanner({
+ to,
+ percentage,
+ phase,
}: {
- isCollapsed: boolean;
- children: ReactNode;
- className?: string;
+ to: string;
+ percentage: number;
+ phase: "shown" | "tracking" | "hidden";
}) {
+ const contentRef = useRef(null);
+ const [height, setHeight] = useState(0);
+
+ useEffect(() => {
+ const el = contentRef.current;
+ if (!el) return;
+ const measure = () => setHeight(el.offsetHeight);
+ measure();
+ const ro = new ResizeObserver(measure);
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, []);
+
+ // Close progress, doubled + clamped so the banner is fully gone by the time the menu is halfway shut.
+ const closeProgress = "min(var(--sm-collapse, 0) * 2, 1)";
+ // Slide a little past its own height to clear the section padding + the viewport edge.
+ const offset = height + 24;
+
+ const maxHeight =
+ phase === "shown"
+ ? height
+ ? `${height}px`
+ : "none"
+ : phase === "hidden"
+ ? "0px"
+ : `calc((1 - ${closeProgress}) * ${height}px)`;
+ // On close the banner no longer slides itself: its reserved height collapses (maxHeight) and, because
+ // the bottom section is pinned to the bottom, that drops Help & Feedback down while the full-height
+ // banner overflows off the bottom edge. Only the pop-up-from-hidden rise uses translateY, so "hidden"
+ // parks it below the edge; "shown" and "tracking" both sit at 0.
+ const translateY = phase === "hidden" ? `${offset}px` : "0px";
+ // Fade out as it slides off (tracking --sm-collapse) and fade back in on the settled-open rise.
+ const opacity = phase === "shown" ? 1 : phase === "hidden" ? 0 : `calc(1 - ${closeProgress})`;
+
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 5a6429fce80..2bc91d77323 100644
--- a/apps/webapp/app/tailwind.css
+++ b/apps/webapp/app/tailwind.css
@@ -339,8 +339,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;
}
}
@@ -348,6 +350,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 {