-
-
-
+
+
+
{environment.type === "DEVELOPMENT" && project.engine === "V2" && (
-
+
+
+
)}
-
+
+
{(user.admin || user.isImpersonating || featureFlags.hasLogsPageAccess) && (
}
+ isCollapsed={isCollapsed}
/>
)}
{(user.admin || user.isImpersonating || featureFlags.hasQueryAccess) && (
}
+ isCollapsed={isCollapsed}
/>
)}
-
- }
- />
-
-
-
+
}
+ isCollapsed={isCollapsed}
/>
{isManagedCloud && (
)}
}
+ isCollapsed={isCollapsed}
/>
-
-
-
-
-
+
+
+
{isFreeUser && (
-
+
+
+
)}
-
+
+
);
@@ -404,11 +553,13 @@ function ProjectSelector({
organization,
organizations,
user,
+ isCollapsed = false,
}: {
project: SideMenuProject;
organization: MatchedOrganization;
organizations: MatchedOrganization[];
user: SideMenuUser;
+ isCollapsed?: boolean;
}) {
const currentPlan = useCurrentPlan();
const [isOrgMenuOpen, setOrgMenuOpen] = useState(false);
@@ -428,21 +579,50 @@ function ProjectSelector({
return (
setOrgMenuOpen(open)} open={isOrgMenuOpen}>
-
-
-
-
-
- {project.name ?? "Select a project"}
-
-
-
+
+
+
+
+
+
+ {project.name ?? "Select a project"}
+
+
+
+
+
+
+
+ }
+ content={`${organization.title} / ${project.name ?? "Select a project"}`}
+ side="right"
+ sideOffset={8}
+ hidden={!isCollapsed}
+ buttonClassName="!h-8"
+ asChild
+ disableHoverableContent
+ />
@@ -661,12 +841,187 @@ function SelectorDivider() {
);
}
-function HelpAndAI() {
+/** Helper component that fades out but preserves width (collapses to 0 width) */
+function CollapsibleElement({
+ isCollapsed,
+ children,
+ className,
+}: {
+ isCollapsed: boolean;
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+/** Helper component that fades out and collapses height completely */
+function CollapsibleHeight({
+ isCollapsed,
+ children,
+ className,
+}: {
+ isCollapsed: boolean;
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+
+ );
+}
+
+function HelpAndAI({ isCollapsed }: { isCollapsed: boolean }) {
return (
- <>
-
-
-
- >
+
+
+
+ );
+}
+
+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"}
+
+
+
+
+
+
+
+
);
}
diff --git a/apps/webapp/app/components/navigation/SideMenuHeader.tsx b/apps/webapp/app/components/navigation/SideMenuHeader.tsx
index 83741a6c7a..8d975cba43 100644
--- a/apps/webapp/app/components/navigation/SideMenuHeader.tsx
+++ b/apps/webapp/app/components/navigation/SideMenuHeader.tsx
@@ -1,9 +1,21 @@
import { useNavigation } from "@remix-run/react";
import { useEffect, useState } from "react";
+import { motion } from "framer-motion";
import { Popover, PopoverContent, PopoverCustomTrigger } from "../primitives/Popover";
import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
-export function SideMenuHeader({ title, children }: { title: string; children?: React.ReactNode }) {
+export function SideMenuHeader({
+ title,
+ children,
+ isCollapsed = false,
+ collapsedTitle,
+}: {
+ title: string;
+ children?: React.ReactNode;
+ isCollapsed?: boolean;
+ /** When provided, this text stays visible when collapsed and the rest fades out */
+ collapsedTitle?: string;
+}) {
const [isHeaderMenuOpen, setHeaderMenuOpen] = useState(false);
const navigation = useNavigation();
@@ -11,9 +23,34 @@ export function SideMenuHeader({ title, children }: { title: string; children?:
setHeaderMenuOpen(false);
}, [navigation.location?.pathname]);
+ // If collapsedTitle is provided and title starts with it, split the title
+ const hasCollapsedTitle = collapsedTitle && title.startsWith(collapsedTitle);
+ const visiblePart = hasCollapsedTitle ? collapsedTitle : title;
+ const fadingPart = hasCollapsedTitle ? title.slice(collapsedTitle.length) : "";
+
return (
-
-
{title}
+
+
+ {visiblePart}
+ {fadingPart && (
+
+ {fadingPart}
+
+ )}
+
{children !== undefined ? (
setHeaderMenuOpen(open)} open={isHeaderMenuOpen}>
@@ -27,6 +64,6 @@ export function SideMenuHeader({ title, children }: { title: string; children?:
) : null}
-
+
);
}
diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx
index 1f89368829..a89765ad44 100644
--- a/apps/webapp/app/components/navigation/SideMenuItem.tsx
+++ b/apps/webapp/app/components/navigation/SideMenuItem.tsx
@@ -1,8 +1,10 @@
import { type AnchorHTMLAttributes, type ReactNode } from "react";
+import { Link } from "@remix-run/react";
+import { motion } from "framer-motion";
import { usePathName } from "~/hooks/usePathName";
import { cn } from "~/utils/cn";
-import { LinkButton } from "../primitives/Buttons";
-import { type RenderIcon } from "../primitives/Icon";
+import { type RenderIcon, Icon } from "../primitives/Icon";
+import { SimpleTooltip } from "../primitives/Tooltip";
export function SideMenuItem({
icon,
@@ -14,6 +16,7 @@ export function SideMenuItem({
to,
badge,
target,
+ isCollapsed = false,
}: {
icon?: RenderIcon;
activeIconColor?: string;
@@ -24,30 +27,67 @@ export function SideMenuItem({
to: string;
badge?: ReactNode;
target?: AnchorHTMLAttributes["target"];
+ isCollapsed?: boolean;
}) {
const pathName = usePathName();
const isActive = pathName === to;
return (
-
-
- {name}
-
{badge !== undefined && badge}
-
-
+
+
+
+ {name}
+ {badge && !isCollapsed && (
+
+ {badge}
+
+ )}
+ {trailingIcon && !isCollapsed && (
+
+ )}
+
+
+ }
+ content={name}
+ side="right"
+ sideOffset={8}
+ buttonClassName="!h-8 block w-full"
+ hidden={!isCollapsed}
+ asChild
+ disableHoverableContent
+ />
);
}
diff --git a/apps/webapp/app/components/navigation/SideMenuSection.tsx b/apps/webapp/app/components/navigation/SideMenuSection.tsx
index af37003575..1f19ffe487 100644
--- a/apps/webapp/app/components/navigation/SideMenuSection.tsx
+++ b/apps/webapp/app/components/navigation/SideMenuSection.tsx
@@ -7,6 +7,9 @@ type Props = {
initialCollapsed?: boolean;
onCollapseToggle?: (isCollapsed: boolean) => void;
children: React.ReactNode;
+ /** When true, hides the section header and shows only children */
+ isSideMenuCollapsed?: boolean;
+ itemSpacingClassName?: string;
};
/** A collapsible section for the side menu
@@ -17,6 +20,8 @@ export function SideMenuSection({
initialCollapsed = false,
onCollapseToggle,
children,
+ isSideMenuCollapsed = false,
+ itemSpacingClassName = "space-y-px",
}: Props) {
const [isCollapsed, setIsCollapsed] = useState(initialCollapsed);
@@ -27,22 +32,42 @@ export function SideMenuSection({
}, [isCollapsed, onCollapseToggle]);
return (
-
-
-
{title}
+
+ {/* Header container - stays in DOM to preserve height */}
+
+ {/* Header - fades out when sidebar is collapsed */}
-
+ {title}
+
+
+
+ {/* Divider - absolutely positioned, visible when sidebar is collapsed but section is expanded */}
+
) {
const ref = React.useRef(null);
useShortcutKeys.useShortcutKeys({
@@ -176,14 +178,14 @@ function PopoverSideMenuTrigger({
{...props}
ref={ref}
className={cn(
- "flex h-[1.8rem] shrink-0 select-none items-center gap-x-1.5 rounded-sm bg-transparent px-[0.4rem] text-center font-sans text-2sm font-normal text-text-bright transition duration-150 focus-custom hover:bg-charcoal-750",
- shortcut ? "justify-between" : "",
+ "flex h-[1.8rem] shrink-0 select-none items-center rounded-sm bg-transparent pl-[0.4rem] pr-2.5 text-center font-sans text-2sm font-normal text-text-bright transition duration-150 focus-custom hover:bg-charcoal-750",
+ shortcut && !hideShortcutKey ? "justify-between gap-x-1.5" : "",
className
)}
>
{children}
- {shortcut && (
-
+ {shortcut && !hideShortcutKey && (
+
)}
);
diff --git a/apps/webapp/app/hooks/useUser.ts b/apps/webapp/app/hooks/useUser.ts
index e31455cf92..fd9938fdb9 100644
--- a/apps/webapp/app/hooks/useUser.ts
+++ b/apps/webapp/app/hooks/useUser.ts
@@ -1,10 +1,12 @@
-import { UIMatch } from "@remix-run/react";
-import type { User } from "~/models/user.server";
-import { loader } from "~/root";
+import { type UIMatch } from "@remix-run/react";
+import type { UserWithDashboardPreferences } from "~/models/user.server";
+import { type loader } from "~/root";
import { useChanged } from "./useChanged";
import { useTypedMatchesData } from "./useTypedMatchData";
import { useIsImpersonating } from "./useOrganizations";
+export type User = UserWithDashboardPreferences;
+
export function useOptionalUser(matches?: UIMatch[]): User | undefined {
const routeMatch = useTypedMatchesData({
id: "root",
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam/route.tsx
index aea6b345ee..3f4be602aa 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam/route.tsx
@@ -19,7 +19,7 @@ export default function Project() {
return (
<>
-
+
();
const fetchIncidents = useCallback(() => {
@@ -36,36 +34,50 @@ export function IncidentStatusPanel() {
}, [fetcher]);
useEffect(() => {
+ if (!isManagedCloud) return;
+
fetchIncidents();
const interval = setInterval(fetchIncidents, 60 * 1000); // 1 minute
return () => clearInterval(interval);
- }, []);
+ }, [isManagedCloud, fetchIncidents]);
const operational = fetcher.data?.operational ?? true;
+ if (!isManagedCloud || operational) {
+ return null;
+ }
+
return (
- <>
- {!operational && (
+
+
+ {/* Expanded panel - animated height and opacity */}
+ {/* Header */}
+
+ {/* Description */}
Our team is working on resolving the issue. Check our status page for more
information.
+
+ {/* Button */}
- )}
- >
+
+ {/* Collapsed button - animated height and opacity */}
+
+
+
+
+ }
+ content="Active incident"
+ side="right"
+ sideOffset={8}
+ disableHoverableContent
+ asChild
+ />
+
+
+
+
+
+
+ );
+}
+
+function IncidentPopoverContent() {
+ return (
+
+
+
+
+ Active incident
+
+
+
+ Our team is working on resolving the issue. Check our status page for more information.
+
+
+ View status page
+
+
);
}
diff --git a/apps/webapp/app/routes/resources.preferences.sidemenu.tsx b/apps/webapp/app/routes/resources.preferences.sidemenu.tsx
new file mode 100644
index 0000000000..9c3edc66be
--- /dev/null
+++ b/apps/webapp/app/routes/resources.preferences.sidemenu.tsx
@@ -0,0 +1,35 @@
+import { json, type ActionFunctionArgs } from "@remix-run/node";
+import { z } from "zod";
+import { updateSideMenuPreferences } from "~/services/dashboardPreferences.server";
+import { requireUser } from "~/services/session.server";
+
+// Transforms form data string "true"/"false" to boolean, or undefined if not present
+const booleanFromFormData = z
+ .enum(["true", "false"])
+ .transform((val) => val === "true")
+ .optional();
+
+const RequestSchema = z.object({
+ isCollapsed: booleanFromFormData,
+ manageSectionCollapsed: booleanFromFormData,
+});
+
+export async function action({ request }: ActionFunctionArgs) {
+ const user = await requireUser(request);
+
+ const formData = await request.formData();
+ const rawData = Object.fromEntries(formData);
+
+ const result = RequestSchema.safeParse(rawData);
+ if (!result.success) {
+ return json({ success: false, error: "Invalid request data" }, { status: 400 });
+ }
+
+ await updateSideMenuPreferences({
+ user,
+ isCollapsed: result.data.isCollapsed,
+ manageSectionCollapsed: result.data.manageSectionCollapsed,
+ });
+
+ return json({ success: true });
+}
diff --git a/apps/webapp/app/services/dashboardPreferences.server.ts b/apps/webapp/app/services/dashboardPreferences.server.ts
index 3649704b81..323b96fa88 100644
--- a/apps/webapp/app/services/dashboardPreferences.server.ts
+++ b/apps/webapp/app/services/dashboardPreferences.server.ts
@@ -3,6 +3,13 @@ import { prisma } from "~/db.server";
import { logger } from "./logger.server";
import { type UserFromSession } from "./session.server";
+const SideMenuPreferences = z.object({
+ isCollapsed: z.boolean().default(false),
+ manageSectionCollapsed: z.boolean().default(false),
+});
+
+export type SideMenuPreferences = z.infer;
+
const DashboardPreferences = z.object({
version: z.literal("1"),
currentProjectId: z.string().optional(),
@@ -12,6 +19,7 @@ const DashboardPreferences = z.object({
currentEnvironment: z.object({ id: z.string() }),
})
),
+ sideMenu: SideMenuPreferences.optional(),
});
export type DashboardPreferences = z.infer;
@@ -99,3 +107,47 @@ export async function clearCurrentProject({ user }: { user: UserFromSession }) {
},
});
}
+
+export async function updateSideMenuPreferences({
+ user,
+ isCollapsed,
+ manageSectionCollapsed,
+}: {
+ user: UserFromSession;
+ isCollapsed?: boolean;
+ manageSectionCollapsed?: boolean;
+}) {
+ if (user.isImpersonating) {
+ return;
+ }
+
+ // Parse with schema to apply defaults, then overlay any new values
+ const currentSideMenu = SideMenuPreferences.parse(user.dashboardPreferences.sideMenu ?? {});
+ const updatedSideMenu = SideMenuPreferences.parse({
+ ...currentSideMenu,
+ ...(isCollapsed !== undefined && { isCollapsed }),
+ ...(manageSectionCollapsed !== undefined && { manageSectionCollapsed }),
+ });
+
+ // Only update if something changed
+ if (
+ updatedSideMenu.isCollapsed === currentSideMenu.isCollapsed &&
+ updatedSideMenu.manageSectionCollapsed === currentSideMenu.manageSectionCollapsed
+ ) {
+ return;
+ }
+
+ const updatedPreferences: DashboardPreferences = {
+ ...user.dashboardPreferences,
+ sideMenu: updatedSideMenu,
+ };
+
+ return prisma.user.update({
+ where: {
+ id: user.id,
+ },
+ data: {
+ dashboardPreferences: updatedPreferences,
+ },
+ });
+}