Feat(webapp): collapsible side menu (#2939)

- Better separation between main feature pages and manage pages
- Toggle via keyboard shortcut Cmd/Ctrl + B or click collapse button
- Collapsed state persisted to database (survives page refresh)
- All menu items show tooltips when collapsed
- Smooth animations using Framer Motion and CSS transitions
- Impersonation-safe (preferences not modified when impersonating)


https://github.com/user-attachments/assets/35827922-b80a-418e-8341-eb22c9bb5ed4


<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2939">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
This commit is contained in:
James Ritchie
2026-01-27 17:13:21 +00:00
committed by GitHub
parent 5fb9cc36bc
commit 49de105862
17 changed files with 963 additions and 233 deletions
+37 -24
View File
@@ -5,6 +5,7 @@ import {
HandThumbUpIcon,
StopIcon,
} from "@heroicons/react/20/solid";
import { cn } from "~/utils/cn";
import { type FeedbackComment, KapaProvider, type QA, useChat } from "@kapaai/react-sdk";
import { useSearchParams } from "@remix-run/react";
import DOMPurify from "dompurify";
@@ -37,7 +38,7 @@ function useKapaWebsiteId() {
return routeMatch?.kapa.websiteId;
}
export function AskAI() {
export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) {
const { isManagedCloud } = useFeatures();
const websiteId = useKapaWebsiteId();
@@ -54,21 +55,23 @@ export function AskAI() {
hideShortcutKey
data-modal-override-open-class-ask-ai="true"
disabled
className={isCollapsed ? "w-full justify-center" : ""}
>
<AISparkleIcon className="size-5" />
</Button>
}
>
{() => <AskAIProvider websiteId={websiteId} />}
{() => <AskAIProvider websiteId={websiteId} isCollapsed={isCollapsed} />}
</ClientOnly>
);
}
type AskAIProviderProps = {
websiteId: string;
isCollapsed?: boolean;
};
function AskAIProvider({ websiteId }: AskAIProviderProps) {
function AskAIProvider({ websiteId, isCollapsed = false }: AskAIProviderProps) {
const [isOpen, setIsOpen] = useState(false);
const [initialQuery, setInitialQuery] = useState<string | undefined>();
const [searchParams, setSearchParams] = useSearchParams();
@@ -112,28 +115,38 @@ function AskAIProvider({ websiteId }: AskAIProviderProps) {
}}
botProtectionMechanism="hcaptcha"
>
<TooltipProvider disableHoverableContent>
<Tooltip>
<TooltipTrigger asChild>
<div className="inline-flex">
<Button
variant="small-menu-item"
data-action="ask-ai"
shortcut={{ modifiers: ["mod"], key: "/", enabledOnInputElements: true }}
hideShortcutKey
data-modal-override-open-class-ask-ai="true"
onClick={() => openAskAI()}
>
<AISparkleIcon className="size-5" />
</Button>
<motion.div layout="position" transition={{ duration: 0.2, ease: "easeInOut" }}>
<TooltipProvider disableHoverableContent>
<Tooltip>
<div className={isCollapsed ? "w-full" : "inline-flex"}>
<TooltipTrigger asChild>
<Button
variant="small-menu-item"
data-action="ask-ai"
shortcut={{ modifiers: ["mod"], key: "/", enabledOnInputElements: true }}
hideShortcutKey
data-modal-override-open-class-ask-ai="true"
onClick={() => openAskAI()}
className={isCollapsed ? "w-full justify-center" : ""}
>
<AISparkleIcon className="size-5" />
</Button>
</TooltipTrigger>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="flex items-center gap-1 py-1.5 pl-2.5 pr-2 text-xs">
Ask AI
<ShortcutKey shortcut={{ modifiers: ["mod"], key: "/" }} variant="medium/bright" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipContent
side={isCollapsed ? "right" : "top"}
sideOffset={isCollapsed ? 8 : 4}
className="flex items-center gap-1 py-1.5 pl-2.5 pr-2 text-xs"
>
Ask AI
<span className="flex items-center">
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "/" }} variant="medium/bright" />
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</motion.div>
<AskAIDialog
initialQuery={initialQuery}
isOpen={isOpen}
+10 -7
View File
@@ -1,18 +1,17 @@
import { Keyboard } from "lucide-react";
import { useState } from "react";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { Button } from "./primitives/Buttons";
import { Header3 } from "./primitives/Headers";
import { Paragraph } from "./primitives/Paragraph";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
SheetTrigger
} from "./primitives/SheetV3";
import { ShortcutKey } from "./primitives/ShortcutKey";
import { Button } from "./primitives/Buttons";
import { useState } from "react";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
export function Shortcuts() {
return (
@@ -26,8 +25,8 @@ export function Shortcuts() {
fullWidth
textAlignLeft
shortcut={{ modifiers: ["shift"], key: "?", enabled: false }}
className="gap-x-0 pl-0.5"
iconSpacing="gap-x-0.5"
className="gap-x-0 pl-1.5"
iconSpacing="gap-x-1.5"
>
Shortcuts
</Button>
@@ -82,6 +81,10 @@ function ShortcutContent() {
<Shortcut name="Filter">
<ShortcutKey shortcut={{ key: "f" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Toggle side menu">
<ShortcutKey shortcut={{ modifiers: ["mod"]}} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "b" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Select filter">
<ShortcutKey shortcut={{ key: "1" }} variant="medium/bright" />
<Paragraph variant="small" className="ml-1.5">
@@ -80,11 +80,13 @@ export function EnvironmentLabel({
className,
tooltipSideOffset = 34,
tooltipSide = "right",
disableTooltip = false,
}: {
environment: Environment;
className?: string;
tooltipSideOffset?: number;
tooltipSide?: "top" | "right" | "bottom" | "left";
disableTooltip?: boolean;
}) {
const spanRef = useRef<HTMLSpanElement>(null);
const [isTruncated, setIsTruncated] = useState(false);
@@ -117,7 +119,7 @@ export function EnvironmentLabel({
</span>
);
if (isTruncated) {
if (isTruncated && !disableTooltip) {
return (
<SimpleTooltip
asChild
@@ -8,10 +8,11 @@ import {
personalAccessTokensPath,
rootPath,
} from "~/utils/pathBuilder";
import { AskAI } from "../AskAI";
import { LinkButton } from "../primitives/Buttons";
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
import { SideMenuHeader } from "./SideMenuHeader";
import { SideMenuItem } from "./SideMenuItem";
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
export function AccountSideMenu({ user }: { user: User }) {
return (
@@ -55,8 +56,9 @@ export function AccountSideMenu({ user }: { user: User }) {
data-action="security"
/>
</div>
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
<div className="flex w-full items-center justify-between border-t border-grid-bright p-1">
<HelpAndFeedback />
<AskAI />
</div>
</div>
);
@@ -1,4 +1,5 @@
import { ChevronRightIcon, Cog8ToothIcon } from "@heroicons/react/20/solid";
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
import { useNavigation } from "@remix-run/react";
import { useEffect, useRef, useState } from "react";
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
@@ -9,19 +10,19 @@ import { useOrganization, type MatchedOrganization } from "~/hooks/useOrganizati
import { useProject } from "~/hooks/useProject";
import { cn } from "~/utils/cn";
import { branchesPath, docsPath, v3BillingPath } from "~/utils/pathBuilder";
import { EnvironmentCombo } from "../environments/EnvironmentLabel";
import { EnvironmentCombo, EnvironmentIcon, EnvironmentLabel, environmentFullTitle } from "../environments/EnvironmentLabel";
import { ButtonContent } from "../primitives/Buttons";
import { Header2 } from "../primitives/Headers";
import { Paragraph } from "../primitives/Paragraph";
import {
Popover,
PopoverArrowTrigger,
PopoverContent,
PopoverMenuItem,
PopoverSectionHeader,
PopoverTrigger,
} from "../primitives/Popover";
import { TextLink } from "../primitives/TextLink";
import { SimpleTooltip } from "../primitives/Tooltip";
import { V4Badge } from "../V4Badge";
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
import { Badge } from "../primitives/Badge";
@@ -31,11 +32,13 @@ export function EnvironmentSelector({
project,
environment,
className,
isCollapsed = false,
}: {
organization: MatchedOrganization;
project: SideMenuProject;
environment: SideMenuEnvironment;
className?: string;
isCollapsed?: boolean;
}) {
const { isManagedCloud } = useFeatures();
const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -50,16 +53,48 @@ export function EnvironmentSelector({
return (
<Popover onOpenChange={(open) => setIsMenuOpen(open)} open={isMenuOpen}>
<PopoverArrowTrigger
isOpen={isMenuOpen}
overflowHidden
fullWidth
className={cn("h-7 overflow-hidden py-1 pl-1.5", className)}
>
<EnvironmentCombo environment={environment} className="w-full text-2sm" />
</PopoverArrowTrigger>
<SimpleTooltip
button={
<PopoverTrigger
className={cn(
"group flex h-8 items-center rounded pl-[0.4375rem] transition-colors hover:bg-charcoal-750",
isCollapsed ? "justify-center pr-0.5" : "justify-between pr-1",
className
)}
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
<EnvironmentIcon environment={environment} className="size-5 shrink-0" />
<span
className={cn(
"flex min-w-0 items-center overflow-hidden transition-all duration-200",
isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"
)}
>
<EnvironmentLabel environment={environment} className="text-2sm" disableTooltip />
</span>
</span>
<span
className={cn(
"overflow-hidden transition-all duration-200",
isCollapsed ? "max-w-0 opacity-0" : "max-w-[16px] opacity-100"
)}
>
<DropdownIcon className="size-4 min-w-4 text-text-dimmed transition group-hover:text-text-bright" />
</span>
</PopoverTrigger>
}
content={environmentFullTitle(environment)}
side="right"
sideOffset={8}
hidden={!isCollapsed}
buttonClassName="!h-8"
asChild
disableHoverableContent
/>
<PopoverContent
className="min-w-[14rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
side={isCollapsed ? "right" : "bottom"}
sideOffset={isCollapsed ? 8 : 4}
align="start"
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
>
@@ -8,9 +8,12 @@ import {
SignalIcon,
StarIcon,
} from "@heroicons/react/20/solid";
import { cn } from "~/utils/cn";
import { DiscordIcon, SlackIcon } from "@trigger.dev/companyicons";
import { Fragment, useState } from "react";
import { motion } from "framer-motion";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { Feedback } from "../Feedback";
import { Shortcuts } from "../Shortcuts";
import { StepContentContainer } from "../StepContentContainer";
@@ -19,30 +22,85 @@ import { ClipboardField } from "../primitives/ClipboardField";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog";
import { Icon } from "../primitives/Icon";
import { Paragraph } from "../primitives/Paragraph";
import { Popover, PopoverContent, PopoverSideMenuTrigger } from "../primitives/Popover";
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
import { SimpleTooltip } from "../primitives/Tooltip";
import { ShortcutKey } from "../primitives/ShortcutKey";
import { StepNumber } from "../primitives/StepNumber";
import { SideMenuItem } from "./SideMenuItem";
import { Badge } from "../primitives/Badge";
export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?: boolean }) {
export function HelpAndFeedback({
disableShortcut = false,
isCollapsed = false,
}: {
disableShortcut?: boolean;
isCollapsed?: boolean;
}) {
const [isHelpMenuOpen, setHelpMenuOpen] = useState(false);
const currentPlan = useCurrentPlan();
useShortcutKeys({
shortcut: disableShortcut ? undefined : { key: "h", enabledOnInputElements: false },
action: (e) => {
e.preventDefault();
e.stopPropagation();
setHelpMenuOpen(true);
},
});
return (
<Popover onOpenChange={(open) => setHelpMenuOpen(open)}>
<PopoverSideMenuTrigger
isOpen={isHelpMenuOpen}
shortcut={{ key: "h", enabledOnInputElements: false }}
className="grow pr-2"
disabled={disableShortcut}
>
<div className="flex items-center gap-1.5">
<QuestionMarkCircleIcon className="size-4 text-success" />
Help & Feedback
</div>
</PopoverSideMenuTrigger>
<motion.div
layout="position"
transition={{ duration: 0.2, ease: "easeInOut" }}
className={isCollapsed ? undefined : "flex-1"}
>
<Popover open={isHelpMenuOpen} onOpenChange={setHelpMenuOpen}>
<SimpleTooltip
button={
<PopoverTrigger
className={cn(
"group flex h-8 items-center gap-1.5 rounded pl-[0.4375rem] pr-2 transition-colors hover:bg-charcoal-750",
isCollapsed ? "w-full" : "w-full justify-between"
)}
>
<span className="flex items-center gap-1.5 overflow-hidden">
<QuestionMarkCircleIcon className="size-5 min-w-5 shrink-0 text-success" />
<span
className={cn(
"overflow-hidden whitespace-nowrap text-2sm text-text-bright transition-all duration-150",
isCollapsed ? "max-w-0 opacity-0" : "max-w-[150px] opacity-100"
)}
>
Help & Feedback
</span>
</span>
<ShortcutKey
className={cn(
"size-4 flex-none transition-all duration-150",
isCollapsed ? "hidden" : ""
)}
shortcut={{ key: "h" }}
variant="medium/bright"
/>
</PopoverTrigger>
}
content={
<span className="flex items-center gap-1">
Help & Feedback
<ShortcutKey shortcut={{ key: "h" }} variant="medium/bright" />
</span>
}
side="right"
sideOffset={8}
hidden={!isCollapsed}
buttonClassName="!h-8 w-full"
asChild
disableHoverableContent
/>
<PopoverContent
className="min-w-[14rem] divide-y divide-grid-bright overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
side={isCollapsed ? "right" : "top"}
sideOffset={isCollapsed ? 8 : 4}
align="start"
>
<Fragment>
@@ -176,8 +234,9 @@ export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?:
button={
<Button
variant="small-menu-item"
className="pl-2"
LeadingIcon={EnvelopeIcon}
leadingIconClassName="text-blue-500"
leadingIconClassName="text-blue-500 pr-1"
data-action="contact-us"
fullWidth
textAlignLeft
@@ -189,6 +248,7 @@ export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?:
</div>
</Fragment>
</PopoverContent>
</Popover>
</Popover>
</motion.div>
);
}
@@ -25,6 +25,7 @@ import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { Paragraph } from "../primitives/Paragraph";
import { Badge } from "../primitives/Badge";
import { useHasAdminAccess } from "~/hooks/useUser";
import { AskAI } from "../AskAI";
export type BuildInfo = {
appVersion: string | undefined;
@@ -144,8 +145,9 @@ export function OrganizationSettingsSideMenu({
</div>
)}
</div>
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
<div className="flex w-full items-center justify-between border-t border-grid-bright p-1">
<HelpAndFeedback />
<AskAI />
</div>
</div>
);
+467 -112
View File
@@ -6,7 +6,6 @@ import {
BellAlertIcon,
ChartBarIcon,
ChevronRightIcon,
CircleStackIcon,
ClockIcon,
Cog8ToothIcon,
CogIcon,
@@ -15,19 +14,20 @@ import {
GlobeAmericasIcon,
IdentificationIcon,
KeyIcon,
MagnifyingGlassCircleIcon,
PencilSquareIcon,
PlusIcon,
RectangleStackIcon,
ServerStackIcon,
Squares2X2Icon,
TableCellsIcon,
UsersIcon,
UsersIcon
} from "@heroicons/react/20/solid";
import { Link, useNavigation } from "@remix-run/react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Link, useFetcher, useNavigation } from "@remix-run/react";
import { LayoutGroup, motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import simplur from "simplur";
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
import { LogsIcon } from "~/assets/icons/LogsIcon";
@@ -41,7 +41,9 @@ import { useFeatures } from "~/hooks/useFeatures";
import { type MatchedOrganization } from "~/hooks/useOrganizations";
import { type MatchedProject } from "~/hooks/useProject";
import { useHasAdminAccess } from "~/hooks/useUser";
import { type User } from "~/models/user.server";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { ShortcutKey } from "../primitives/ShortcutKey";
import { type UserWithDashboardPreferences } from "~/models/user.server";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { type FeedbackType } from "~/routes/resources.feedback";
import { IncidentStatusPanel } from "~/routes/resources.incidents";
@@ -78,6 +80,7 @@ import {
v3UsagePath,
v3WaitpointTokensPath,
} from "~/utils/pathBuilder";
import { AlphaBadge } from "../AlphaBadge";
import { AskAI } from "../AskAI";
import { FreePlanUsage } from "../billing/FreePlanUsage";
import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence";
@@ -87,24 +90,23 @@ import { Dialog, DialogTrigger } from "../primitives/Dialog";
import { Paragraph } from "../primitives/Paragraph";
import {
Popover,
PopoverArrowTrigger,
PopoverContent,
PopoverMenuItem,
PopoverTrigger,
PopoverTrigger
} from "../primitives/Popover";
import { TextLink } from "../primitives/TextLink";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
import { SimpleTooltip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
import { ShortcutsAutoOpen } from "../Shortcuts";
import { UserProfilePhoto } from "../UserProfilePhoto";
import { V4Badge } from "../V4Badge";
import { EnvironmentSelector } from "./EnvironmentSelector";
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
import { SideMenuHeader } from "./SideMenuHeader";
import { SideMenuItem } from "./SideMenuItem";
import { SideMenuSection } from "./SideMenuSection";
import { AlphaBadge } from "../AlphaBadge";
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
type SideMenuUser = Pick<UserWithDashboardPreferences, "email" | "admin" | "dashboardPreferences"> & {
isImpersonating: boolean;
};
export type SideMenuProject = Pick<
MatchedProject,
"id" | "name" | "slug" | "version" | "environments" | "engine"
@@ -130,6 +132,15 @@ export function SideMenu({
}: SideMenuProps) {
const borderRef = useRef<HTMLDivElement>(null);
const [showHeaderDivider, setShowHeaderDivider] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(
user.dashboardPreferences.sideMenu?.isCollapsed ?? false
);
const preferencesFetcher = useFetcher();
const pendingPreferencesRef = useRef<{
isCollapsed?: boolean;
manageSectionCollapsed?: boolean;
}>({});
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const currentPlan = useCurrentPlan();
const { isConnected } = useDevPresence();
const isFreeUser = currentPlan?.v3Subscription?.isPaying === false;
@@ -137,6 +148,84 @@ export function SideMenu({
const { isManagedCloud } = useFeatures();
const featureFlags = useFeatureFlags();
const persistSideMenuPreferences = useCallback(
(data: { isCollapsed?: boolean; manageSectionCollapsed?: boolean }) => {
if (user.isImpersonating) return;
// Merge with any pending changes
pendingPreferencesRef.current = {
...pendingPreferencesRef.current,
...data,
};
// Clear existing timeout
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
// Debounce the actual submission by 500ms
debounceTimeoutRef.current = setTimeout(() => {
const pending = pendingPreferencesRef.current;
const formData = new FormData();
if (pending.isCollapsed !== undefined) {
formData.append("isCollapsed", String(pending.isCollapsed));
}
if (pending.manageSectionCollapsed !== undefined) {
formData.append("manageSectionCollapsed", String(pending.manageSectionCollapsed));
}
preferencesFetcher.submit(formData, {
method: "POST",
action: "/resources/preferences/sidemenu",
});
pendingPreferencesRef.current = {};
}, 500);
},
[user.isImpersonating, preferencesFetcher]
);
// Flush pending preferences on unmount to avoid losing the last toggle
useEffect(() => {
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
if (user.isImpersonating) return;
const pending = pendingPreferencesRef.current;
if (pending.isCollapsed !== undefined || pending.manageSectionCollapsed !== undefined) {
const formData = new FormData();
if (pending.isCollapsed !== undefined) {
formData.append("isCollapsed", String(pending.isCollapsed));
}
if (pending.manageSectionCollapsed !== undefined) {
formData.append("manageSectionCollapsed", String(pending.manageSectionCollapsed));
}
preferencesFetcher.submit(formData, {
method: "POST",
action: "/resources/preferences/sidemenu",
});
pendingPreferencesRef.current = {};
}
};
}, [preferencesFetcher, user.isImpersonating]);
const handleToggleCollapsed = () => {
const newIsCollapsed = !isCollapsed;
setIsCollapsed(newIsCollapsed);
persistSideMenuPreferences({ isCollapsed: newIsCollapsed });
};
const handleManageSectionToggle = useCallback(
(collapsed: boolean) => {
persistSideMenuPreferences({ manageSectionCollapsed: collapsed });
},
[persistSideMenuPreferences]
);
useShortcutKeys({
shortcut: { modifiers: ["mod"], key: "b", enabledOnInputElements: true },
action: handleToggleCollapsed,
});
useEffect(() => {
const handleScroll = () => {
if (borderRef.current) {
@@ -154,246 +243,306 @@ export function SideMenu({
return (
<div
className={cn(
"grid h-full grid-rows-[2.5rem_1fr_auto] overflow-hidden border-r border-grid-bright bg-background-bright transition"
"relative h-full border-r border-grid-bright bg-background-bright transition-all duration-200",
isCollapsed ? "w-[2.75rem]" : "w-56"
)}
>
<div
className={cn(
"flex items-center overflow-hidden border-b px-1 py-1 transition duration-300",
showHeaderDivider ? "border-grid-bright" : "border-transparent"
)}
>
<div className="min-w-0 flex-1">
<CollapseToggle isCollapsed={isCollapsed} onToggle={handleToggleCollapsed} />
<div className="absolute inset-0 grid grid-cols-[100%] grid-rows-[2.5rem_1fr_auto] overflow-hidden">
<div
className={cn(
"flex min-w-0 items-center overflow-hidden border-b px-1 py-1 transition duration-300",
showHeaderDivider || isCollapsed ? "border-grid-bright" : "border-transparent"
)}
>
<div className={cn("min-w-0", !isCollapsed && "flex-1")}>
<ProjectSelector
organizations={organizations}
organization={organization}
project={project}
user={user}
isCollapsed={isCollapsed}
/>
</div>
{isAdmin && !user.isImpersonating ? (
<TooltipProvider disableHoverableContent={true}>
<Tooltip>
<TooltipTrigger>
<LinkButton variant="minimal/medium" to={adminPath()} TrailingIcon={UsersIcon} />
</TooltipTrigger>
<TooltipContent side="bottom" className={"text-xs"}>
Admin dashboard
</TooltipContent>
</Tooltip>
</TooltipProvider>
<CollapsibleElement isCollapsed={isCollapsed}>
<TooltipProvider disableHoverableContent={true}>
<Tooltip>
<TooltipTrigger>
<LinkButton variant="minimal/medium" to={adminPath()} TrailingIcon={UsersIcon} />
</TooltipTrigger>
<TooltipContent side="bottom" className={"text-xs"}>
Admin dashboard
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CollapsibleElement>
) : isAdmin && user.isImpersonating ? (
<ImpersonationBanner />
<CollapsibleElement isCollapsed={isCollapsed}>
<ImpersonationBanner />
</CollapsibleElement>
) : null}
</div>
<div
className="overflow-hidden overflow-y-auto pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
className={cn(
"min-h-0 overflow-y-auto pt-2",
isCollapsed
? "scrollbar-none"
: "scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
)}
ref={borderRef}
>
<div className="mb-6 flex flex-col gap-4 px-1">
<div className="space-y-1">
<SideMenuHeader title={"Environment"} />
<div className="mb-6 flex w-full flex-col gap-4 overflow-hidden px-1">
<div className="w-full space-y-1">
<SideMenuHeader title={"Environment"} isCollapsed={isCollapsed} collapsedTitle="Env" />
<div className="flex items-center">
<EnvironmentSelector
organization={organization}
project={project}
environment={environment}
className="w-full"
isCollapsed={isCollapsed}
/>
{environment.type === "DEVELOPMENT" && project.engine === "V2" && (
<Dialog>
<TooltipProvider disableHoverableContent={true}>
<Tooltip>
<TooltipTrigger asChild>
<div className="inline-flex">
<DialogTrigger asChild>
<Button
variant="minimal/small"
className="aspect-square h-7 p-1"
LeadingIcon={<ConnectionIcon isConnected={isConnected} />}
/>
</DialogTrigger>
</div>
</TooltipTrigger>
<TooltipContent side="right" className={"text-xs"}>
{isConnected === undefined
? "Checking connection..."
: isConnected
? "Your dev server is connected"
: "Your dev server is not connected"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DevPresencePanel isConnected={isConnected} />
</Dialog>
<CollapsibleElement isCollapsed={isCollapsed}>
<Dialog>
<TooltipProvider disableHoverableContent={true}>
<Tooltip>
<TooltipTrigger asChild>
<div className="inline-flex">
<DialogTrigger asChild>
<Button
variant="minimal/small"
className="aspect-square h-7 p-1"
LeadingIcon={<ConnectionIcon isConnected={isConnected} />}
/>
</DialogTrigger>
</div>
</TooltipTrigger>
<TooltipContent side="right" className={"text-xs"}>
{isConnected === undefined
? "Checking connection..."
: isConnected
? "Your dev server is connected"
: "Your dev server is not connected"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DevPresencePanel isConnected={isConnected} />
</Dialog>
</CollapsibleElement>
)}
</div>
</div>
<div>
<div className="w-full">
<SideMenuItem
name="Tasks"
icon={TaskIconSmall}
activeIconColor="text-tasks"
inactiveIconColor="text-tasks"
to={v3EnvironmentPath(organization, project, environment)}
data-action="tasks"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Runs"
icon={RunsIconExtraSmall}
activeIconColor="text-runs"
inactiveIconColor="text-runs"
to={v3RunsPath(organization, project, environment)}
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Batches"
icon={Squares2X2Icon}
activeIconColor="text-batches"
inactiveIconColor="text-batches"
to={v3BatchesPath(organization, project, environment)}
data-action="batches"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Schedules"
icon={ClockIcon}
activeIconColor="text-schedules"
inactiveIconColor="text-schedules"
to={v3SchedulesPath(organization, project, environment)}
data-action="schedules"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Queues"
icon={RectangleStackIcon}
activeIconColor="text-queues"
inactiveIconColor="text-queues"
to={v3QueuesPath(organization, project, environment)}
data-action="queues"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Waitpoint tokens"
icon={WaitpointTokenIcon}
activeIconColor="text-sky-500"
inactiveIconColor="text-sky-500"
to={v3WaitpointTokensPath(organization, project, environment)}
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Deployments"
icon={ServerStackIcon}
activeIconColor="text-deployments"
inactiveIconColor="text-deployments"
to={v3DeploymentsPath(organization, project, environment)}
data-action="deployments"
isCollapsed={isCollapsed}
/>
{(user.admin || user.isImpersonating || featureFlags.hasLogsPageAccess) && (
<SideMenuItem
name="Logs"
icon={LogsIcon}
activeIconColor="text-logs"
inactiveIconColor="text-logs"
to={v3LogsPath(organization, project, environment)}
data-action="logs"
badge={<AlphaBadge />}
isCollapsed={isCollapsed}
/>
)}
<SideMenuItem
name="Test"
icon={BeakerIcon}
activeIconColor="text-tests"
inactiveIconColor="text-tests"
to={v3TestPath(organization, project, environment)}
data-action="test"
isCollapsed={isCollapsed}
/>
{(user.admin || user.isImpersonating || featureFlags.hasQueryAccess) && (
<SideMenuItem
name="Query"
icon={TableCellsIcon}
activeIconColor="text-purple-500"
inactiveIconColor="text-purple-500"
to={queryPath(organization, project, environment)}
data-action="query"
badge={<AlphaBadge />}
isCollapsed={isCollapsed}
/>
)}
</div>
<SideMenuSection title="Waitpoints">
<SideMenuItem
name="Tokens"
icon={WaitpointTokenIcon}
activeIconColor="text-sky-500"
to={v3WaitpointTokensPath(organization, project, environment)}
badge={<V4Badge />}
/>
</SideMenuSection>
<SideMenuSection title="Manage">
<SideMenuSection
title="Manage"
isSideMenuCollapsed={isCollapsed}
itemSpacingClassName="space-y-0"
initialCollapsed={user.dashboardPreferences.sideMenu?.manageSectionCollapsed ?? false}
onCollapseToggle={handleManageSectionToggle}
>
<SideMenuItem
name="Bulk actions"
icon={ListCheckedIcon}
activeIconColor="text-bulkActions"
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3BulkActionsPath(organization, project, environment)}
data-action="bulk actions"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="API keys"
icon={KeyIcon}
activeIconColor="text-apiKeys"
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ApiKeysPath(organization, project, environment)}
data-action="api keys"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Environment variables"
icon={IdentificationIcon}
activeIconColor="text-environmentVariables"
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3EnvironmentVariablesPath(organization, project, environment)}
data-action="environment variables"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Alerts"
icon={BellAlertIcon}
activeIconColor="text-alerts"
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ProjectAlertsPath(organization, project, environment)}
data-action="alerts"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Preview branches"
icon={BranchEnvironmentIconSmall}
activeIconColor="text-preview"
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={branchesPath(organization, project, environment)}
data-action="preview-branches"
badge={<V4Badge />}
isCollapsed={isCollapsed}
/>
{isManagedCloud && (
<SideMenuItem
name="Concurrency"
icon={ConcurrencyIcon}
activeIconColor="text-concurrency"
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={concurrencyPath(organization, project, environment)}
data-action="concurrency"
isCollapsed={isCollapsed}
/>
)}
<SideMenuItem
name="Regions"
icon={GlobeAmericasIcon}
activeIconColor="text-regions"
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={regionsPath(organization, project, environment)}
data-action="regions"
badge={<V4Badge />}
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Limits"
icon={AdjustmentsHorizontalIcon}
activeIconColor="text-limits"
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={limitsPath(organization, project, environment)}
data-action="limits"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Project settings"
icon={Cog8ToothIcon}
activeIconColor="text-projectSettings"
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ProjectSettingsPath(organization, project, environment)}
data-action="project-settings"
isCollapsed={isCollapsed}
/>
</SideMenuSection>
</div>
</div>
<div>
<IncidentStatusPanel />
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
<div className="flex w-full items-center justify-between">
<HelpAndAI />
</div>
<IncidentStatusPanel isCollapsed={isCollapsed} />
<motion.div
layout
transition={{ duration: 0.2, ease: "easeInOut" }}
className={cn("flex flex-col gap-1 border-t border-grid-bright p-1", isCollapsed && "items-center")}
>
<HelpAndAI isCollapsed={isCollapsed} />
{isFreeUser && (
<FreePlanUsage
to={v3BillingPath(organization)}
percentage={currentPlan.v3Usage.usagePercentage}
/>
<CollapsibleHeight isCollapsed={isCollapsed}>
<FreePlanUsage
to={v3BillingPath(organization)}
percentage={currentPlan.v3Usage.usagePercentage}
/>
</CollapsibleHeight>
)}
</div>
</motion.div>
</div>
</div>
</div>
);
@@ -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 (
<Popover onOpenChange={(open) => setOrgMenuOpen(open)} open={isOrgMenuOpen}>
<PopoverArrowTrigger
isOpen={isOrgMenuOpen}
overflowHidden
className="h-8 w-full justify-between py-1 pl-1.5"
>
<span className="flex items-center gap-1.5 overflow-hidden">
<Avatar avatar={organization.avatar} size={1.25} orgName={organization.title} />
<SelectorDivider />
<span className="truncate text-2sm font-normal text-text-bright">
{project.name ?? "Select a project"}
</span>
</span>
</PopoverArrowTrigger>
<SimpleTooltip
button={
<PopoverTrigger
className={cn(
"group flex h-8 items-center rounded pl-[0.4375rem] transition-colors hover:bg-charcoal-750",
isCollapsed ? "justify-center pr-0.5" : "w-full justify-between pr-1"
)}
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
<Avatar avatar={organization.avatar} size={1.25} orgName={organization.title} />
<span
className={cn(
"flex min-w-0 items-center gap-1.5 overflow-hidden transition-all duration-200",
isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"
)}
>
<SelectorDivider />
<span className="truncate text-2sm font-normal text-text-bright">
{project.name ?? "Select a project"}
</span>
</span>
</span>
<span
className={cn(
"overflow-hidden transition-all duration-200",
isCollapsed ? "max-w-0 opacity-0" : "max-w-[16px] opacity-100"
)}
>
<DropdownIcon className="size-4 min-w-4 text-text-dimmed transition group-hover:text-text-bright" />
</span>
</PopoverTrigger>
}
content={`${organization.title} / ${project.name ?? "Select a project"}`}
side="right"
sideOffset={8}
hidden={!isCollapsed}
buttonClassName="!h-8"
asChild
disableHoverableContent
/>
<PopoverContent
className="min-w-[16rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
side={isCollapsed ? "right" : "bottom"}
sideOffset={isCollapsed ? 8 : 4}
align="start"
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
>
@@ -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 (
<>
<ShortcutsAutoOpen />
<HelpAndFeedback />
<AskAI />
</>
<div
className={cn(
"overflow-hidden transition-all duration-200",
isCollapsed ? "max-w-0 opacity-0" : "max-w-[100px] opacity-100",
className
)}
>
{children}
</div>
);
}
/** Helper component that fades out and collapses height completely */
function CollapsibleHeight({
isCollapsed,
children,
className,
}: {
isCollapsed: boolean;
children: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"grid transition-all duration-200 ease-in-out",
isCollapsed ? "grid-rows-[0fr] opacity-0" : "grid-rows-[1fr] opacity-100",
className
)}
>
<div className="overflow-hidden">{children}</div>
</div>
);
}
function HelpAndAI({ isCollapsed }: { isCollapsed: boolean }) {
return (
<LayoutGroup>
<div className={cn("flex w-full", isCollapsed ? "flex-col-reverse gap-1" : "items-center justify-between")}>
<ShortcutsAutoOpen />
<HelpAndFeedback isCollapsed={isCollapsed} />
<AskAI isCollapsed={isCollapsed} />
</div>
</LayoutGroup>
);
}
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 (
<motion.svg
width="4"
height="30"
viewBox="0 0 4 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="pointer-events-none relative z-10 overflow-visible text-charcoal-600 group-hover:text-text-bright transition-colors"
initial={false}
animate={{
x: getTranslateX(),
}}
transition={{ duration: 0.4, ease: "easeOut" }}
>
{/* Top segment */}
<motion.line
x1="2"
y1="1.5"
x2="2"
y2="15"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
initial={false}
animate={{
rotate: top,
}}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{ transformOrigin: "2px 15px" }}
/>
{/* Bottom segment */}
<motion.line
x1="2"
y1="15"
x2="2"
y2="28.5"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
initial={false}
animate={{
rotate: bottom,
}}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{ transformOrigin: "2px 15px" }}
/>
</motion.svg>
);
}
function CollapseToggle({
isCollapsed,
onToggle,
}: {
isCollapsed: boolean;
onToggle: () => void;
}) {
const [isHovering, setIsHovering] = useState(false);
return (
<div className="absolute -right-3 top-1/2 z-10 -translate-y-1/2">
{/* Vertical line to mask the side menu border */}
<div className="pointer-events-none absolute left-1/2 top-1/2 h-10 w-px -translate-y-1/2 bg-background-bright" />
<TooltipProvider disableHoverableContent>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={isCollapsed ? "Expand side menu" : "Collapse side menu"}
onClick={onToggle}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
className={cn(
"group flex h-12 w-6 items-center justify-center rounded-md text-text-dimmed transition-all duration-200 focus-custom",
isHovering
? "border border-grid-bright bg-background-bright shadow-md hover:bg-charcoal-750 hover:text-text-bright"
: "border border-transparent bg-transparent"
)}
>
<AnimatedChevron isHovering={isHovering} isCollapsed={isCollapsed} />
</button>
</TooltipTrigger>
<TooltipContent side="right" className="flex items-center gap-2 text-xs">
{isCollapsed ? "Expand" : "Collapse"}
<span className="flex items-center">
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "b" }} variant="medium/bright" />
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
@@ -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 (
<div className="group flex items-center justify-between pl-1.5">
<h2 className="text-xs">{title}</h2>
<motion.div
className="group flex h-4 items-center justify-between overflow-hidden pl-1.5"
initial={false}
animate={{
opacity: hasCollapsedTitle ? 1 : isCollapsed ? 0 : 1,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
>
<h2 className="text-xs whitespace-nowrap">
{visiblePart}
{fadingPart && (
<motion.span
initial={false}
animate={{
opacity: isCollapsed ? 0 : 1,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
>
{fadingPart}
</motion.span>
)}
</h2>
{children !== undefined ? (
<Popover onOpenChange={(open) => setHeaderMenuOpen(open)} open={isHeaderMenuOpen}>
<PopoverCustomTrigger className="p-1">
@@ -27,6 +64,6 @@ export function SideMenuHeader({ title, children }: { title: string; children?:
</PopoverContent>
</Popover>
) : null}
</div>
</motion.div>
);
}
@@ -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<HTMLAnchorElement>["target"];
isCollapsed?: boolean;
}) {
const pathName = usePathName();
const isActive = pathName === to;
return (
<LinkButton
variant="small-menu-item"
fullWidth
textAlignLeft
LeadingIcon={icon}
leadingIconClassName={isActive ? activeIconColor : inactiveIconColor ?? "text-text-dimmed"}
TrailingIcon={trailingIcon}
trailingIconClassName={trailingIconClassName}
to={to}
target={target}
className={cn(
"text-text-bright group-hover:bg-charcoal-750 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
isActive ? "bg-tertiary text-text-bright" : "group-hover:text-text-bright"
)}
>
<div className="flex w-full items-center justify-between">
{name}
<div className="flex items-center gap-1">{badge !== undefined && badge}</div>
</div>
</LinkButton>
<SimpleTooltip
button={
<Link
to={to}
target={target}
className={cn(
"flex h-8 w-full items-center gap-2 overflow-hidden rounded pr-2 pl-[0.4375rem] text-text-bright transition-colors hover:bg-charcoal-750",
isActive ? "bg-tertiary" : ""
)}
>
<Icon
icon={icon}
className={cn(
"size-5 shrink-0",
isActive ? activeIconColor : inactiveIconColor ?? "text-text-dimmed"
)}
/>
<motion.div
className="flex min-w-0 flex-1 items-center justify-between overflow-hidden"
initial={false}
animate={{
width: isCollapsed ? 0 : "auto",
opacity: isCollapsed ? 0 : 1,
}}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<span className="truncate text-2sm">{name}</span>
{badge && !isCollapsed && (
<motion.div
className="ml-1 flex shrink-0 items-center gap-1"
initial={false}
animate={{
opacity: 1,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
>
{badge}
</motion.div>
)}
{trailingIcon && !isCollapsed && (
<Icon
icon={trailingIcon}
className={cn("ml-1 size-4 shrink-0", trailingIconClassName)}
/>
)}
</motion.div>
</Link>
}
content={name}
side="right"
sideOffset={8}
buttonClassName="!h-8 block w-full"
hidden={!isCollapsed}
asChild
disableHoverableContent
/>
);
}
@@ -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 (
<div>
<div
className="flex cursor-pointer items-center gap-1 rounded-sm py-1 pl-1.5 text-text-dimmed transition hover:bg-charcoal-750 hover:text-text-bright"
onClick={handleToggle}
>
<h2 className="text-xs">{title}</h2>
<div className="w-full overflow-hidden">
{/* Header container - stays in DOM to preserve height */}
<div className="relative w-full">
{/* Header - fades out when sidebar is collapsed */}
<motion.div
initial={isCollapsed}
animate={{ rotate: isCollapsed ? -90 : 0 }}
transition={{ duration: 0.2 }}
className="flex cursor-pointer items-center gap-1 overflow-hidden rounded-sm py-1 pl-1.5 text-text-dimmed transition hover:bg-charcoal-750 hover:text-text-bright"
onClick={isSideMenuCollapsed ? undefined : handleToggle}
style={{ cursor: isSideMenuCollapsed ? "default" : "pointer" }}
initial={false}
animate={{
opacity: isSideMenuCollapsed ? 0 : 1,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
>
<ToggleArrowIcon className="size-2" />
<h2 className="text-xs whitespace-nowrap">{title}</h2>
<motion.div
initial={isCollapsed}
animate={{ rotate: isCollapsed ? -90 : 0 }}
transition={{ duration: 0.2 }}
>
<ToggleArrowIcon className="size-2" />
</motion.div>
</motion.div>
{/* Divider - absolutely positioned, visible when sidebar is collapsed but section is expanded */}
<motion.div
className="absolute left-2 right-2 top-1 h-px bg-charcoal-600"
initial={false}
animate={{
opacity: isSideMenuCollapsed && !isCollapsed ? 1 : 0,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
/>
</div>
<AnimatePresence initial={false}>
<motion.div
className="w-full"
initial={isCollapsed ? "collapsed" : "expanded"}
animate={isCollapsed ? "collapsed" : "expanded"}
exit="collapsed"
@@ -63,6 +88,7 @@ export function SideMenuSection({
style={{ overflow: "hidden" }}
>
<motion.div
className={`w-full ${itemSpacingClassName}`}
variants={{
expanded: {
translateY: 0,
@@ -154,10 +154,12 @@ function PopoverSideMenuTrigger({
children,
className,
shortcut,
hideShortcutKey = false,
...props
}: {
isOpen?: boolean;
shortcut?: useShortcutKeys.ShortcutDefinition;
hideShortcutKey?: boolean;
} & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
const ref = React.useRef<HTMLButtonElement>(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 && (
<ShortcutKey className={cn("size-4 flex-none")} shortcut={shortcut} variant={"small"} />
{shortcut && !hideShortcutKey && (
<ShortcutKey className="size-4 flex-none" shortcut={shortcut} variant={"small"} />
)}
</PopoverTrigger>
);
+5 -3
View File
@@ -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<typeof loader>({
id: "root",
@@ -19,7 +19,7 @@ export default function Project() {
return (
<>
<div className="grid grid-cols-[14rem_1fr] overflow-hidden">
<div className="grid grid-cols-[auto_1fr] overflow-hidden">
<DevPresenceProvider enabled={environment.type === "DEVELOPMENT"}>
<SideMenu
user={{ ...user, isImpersonating }}
+80 -16
View File
@@ -1,10 +1,12 @@
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import { json } from "@remix-run/node";
import { useFetcher } from "@remix-run/react";
import { useCallback, useEffect } from "react";
import { motion } from "framer-motion";
import { useCallback, useEffect } from "react";
import { LinkButton } from "~/components/primitives/Buttons";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { useFeatures } from "~/hooks/useFeatures";
import { BetterStackClient } from "~/services/betterstack/betterstack.server";
@@ -21,12 +23,8 @@ export async function loader() {
});
}
export function IncidentStatusPanel() {
export function IncidentStatusPanel({ isCollapsed = false }: { isCollapsed?: boolean }) {
const { isManagedCloud } = useFeatures();
if (!isManagedCloud) {
return null;
}
const fetcher = useFetcher<typeof loader>();
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 && (
<Popover>
<div className="p-1">
{/* Expanded panel - animated height and opacity */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className="p-1"
initial={false}
animate={{
height: isCollapsed ? 0 : "auto",
opacity: isCollapsed ? 0 : 1,
}}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<div className="flex flex-col gap-2 rounded border border-warning/20 bg-warning/5 p-2 pt-1.5">
{/* Header */}
<div className="flex items-center gap-1 border-b border-warning/20 pb-1 text-warning">
<ExclamationTriangleIcon className="size-4" />
<Paragraph variant="small/bright" className="text-warning">
Active incident
</Paragraph>
</div>
{/* Description */}
<Paragraph variant="extra-small/bright" className="text-warning/80">
Our team is working on resolving the issue. Check our status page for more
information.
</Paragraph>
{/* Button */}
<LinkButton
variant="secondary/small"
to="https://status.trigger.dev"
@@ -77,7 +89,59 @@ export function IncidentStatusPanel() {
</LinkButton>
</div>
</motion.div>
)}
</>
{/* Collapsed button - animated height and opacity */}
<motion.div
initial={false}
animate={{
height: isCollapsed ? "auto" : 0,
opacity: isCollapsed ? 1 : 0,
}}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<SimpleTooltip
button={
<PopoverTrigger className="flex !h-8 w-full items-center justify-center rounded border border-warning/20 bg-warning/10 transition-colors hover:border-warning/30 hover:bg-warning/20">
<ExclamationTriangleIcon className="size-5 text-warning" />
</PopoverTrigger>
}
content="Active incident"
side="right"
sideOffset={8}
disableHoverableContent
asChild
/>
</motion.div>
</div>
<PopoverContent side="right" sideOffset={8} align="start" className="!min-w-0 w-52 p-0">
<IncidentPopoverContent />
</PopoverContent>
</Popover>
);
}
function IncidentPopoverContent() {
return (
<div className="flex flex-col gap-2 rounded border border-warning/20 bg-warning/5 p-2 pt-1.5">
<div className="flex items-center gap-1 border-b border-warning/20 pb-1 text-warning">
<ExclamationTriangleIcon className="size-4" />
<Paragraph variant="small/bright" className="text-warning">
Active incident
</Paragraph>
</div>
<Paragraph variant="extra-small/bright" className="text-warning/80">
Our team is working on resolving the issue. Check our status page for more information.
</Paragraph>
<LinkButton
variant="secondary/small"
to="https://status.trigger.dev"
target="_blank"
fullWidth
className="border-warning/20 bg-warning/10 hover:!border-warning/30 hover:!bg-warning/20"
>
<span className="text-warning">View status page</span>
</LinkButton>
</div>
);
}
@@ -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 });
}
@@ -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<typeof SideMenuPreferences>;
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<typeof DashboardPreferences>;
@@ -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,
},
});
}