diff --git a/apps/webapp/app/components/AskAI.tsx b/apps/webapp/app/components/AskAI.tsx index 39cc4cdaaf..bc55469b84 100644 --- a/apps/webapp/app/components/AskAI.tsx +++ b/apps/webapp/app/components/AskAI.tsx @@ -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" : ""} > } > - {() => } + {() => } ); } 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(); const [searchParams, setSearchParams] = useSearchParams(); @@ -112,28 +115,38 @@ function AskAIProvider({ websiteId }: AskAIProviderProps) { }} botProtectionMechanism="hcaptcha" > - - - -
- + + + +
+ + +
- - - Ask AI - - -
-
+ + Ask AI + + + + + + + +
Shortcuts @@ -82,6 +81,10 @@ function ShortcutContent() { + + + + diff --git a/apps/webapp/app/components/environments/EnvironmentLabel.tsx b/apps/webapp/app/components/environments/EnvironmentLabel.tsx index 929655f546..57d70406cc 100644 --- a/apps/webapp/app/components/environments/EnvironmentLabel.tsx +++ b/apps/webapp/app/components/environments/EnvironmentLabel.tsx @@ -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(null); const [isTruncated, setIsTruncated] = useState(false); @@ -117,7 +119,7 @@ export function EnvironmentLabel({ ); - if (isTruncated) { + if (isTruncated && !disableTooltip) { return (
-
+
+
); diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index def3996f85..ba3cce8232 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -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 ( setIsMenuOpen(open)} open={isMenuOpen}> - - - + + + + + + + + + + + + } + content={environmentFullTitle(environment)} + side="right" + sideOffset={8} + hidden={!isCollapsed} + buttonClassName="!h-8" + asChild + disableHoverableContent + /> diff --git a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx index 93f2843de5..74077eed72 100644 --- a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx +++ b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx @@ -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 ( - setHelpMenuOpen(open)}> - -
- - Help & Feedback -
-
+ + + + + + + Help & Feedback + + + + + } + content={ + + Help & Feedback + + + } + side="right" + sideOffset={8} + hidden={!isCollapsed} + buttonClassName="!h-8 w-full" + asChild + disableHoverableContent + /> @@ -176,8 +234,9 @@ export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?: button={ +
+ + {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 */}
Active incident
+ + {/* 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, + }, + }); +}