Skip to content

Commit 49de105

Browse files
authored
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 -->
1 parent 5fb9cc3 commit 49de105

File tree

17 files changed

+963
-233
lines changed

17 files changed

+963
-233
lines changed

apps/webapp/app/components/AskAI.tsx

Lines changed: 37 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
HandThumbUpIcon,
66
StopIcon,
77
} from "@heroicons/react/20/solid";
8+
import { cn } from "~/utils/cn";
89
import { type FeedbackComment, KapaProvider, type QA, useChat } from "@kapaai/react-sdk";
910
import { useSearchParams } from "@remix-run/react";
1011
import DOMPurify from "dompurify";
@@ -37,7 +38,7 @@ function useKapaWebsiteId() {
3738
return routeMatch?.kapa.websiteId;
3839
}
3940

40-
export function AskAI() {
41+
export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) {
4142
const { isManagedCloud } = useFeatures();
4243
const websiteId = useKapaWebsiteId();
4344

@@ -54,21 +55,23 @@ export function AskAI() {
5455
hideShortcutKey
5556
data-modal-override-open-class-ask-ai="true"
5657
disabled
58+
className={isCollapsed ? "w-full justify-center" : ""}
5759
>
5860
<AISparkleIcon className="size-5" />
5961
</Button>
6062
}
6163
>
62-
{() => <AskAIProvider websiteId={websiteId} />}
64+
{() => <AskAIProvider websiteId={websiteId} isCollapsed={isCollapsed} />}
6365
</ClientOnly>
6466
);
6567
}
6668

6769
type AskAIProviderProps = {
6870
websiteId: string;
71+
isCollapsed?: boolean;
6972
};
7073

71-
function AskAIProvider({ websiteId }: AskAIProviderProps) {
74+
function AskAIProvider({ websiteId, isCollapsed = false }: AskAIProviderProps) {
7275
const [isOpen, setIsOpen] = useState(false);
7376
const [initialQuery, setInitialQuery] = useState<string | undefined>();
7477
const [searchParams, setSearchParams] = useSearchParams();
@@ -112,28 +115,38 @@ function AskAIProvider({ websiteId }: AskAIProviderProps) {
112115
}}
113116
botProtectionMechanism="hcaptcha"
114117
>
115-
<TooltipProvider disableHoverableContent>
116-
<Tooltip>
117-
<TooltipTrigger asChild>
118-
<div className="inline-flex">
119-
<Button
120-
variant="small-menu-item"
121-
data-action="ask-ai"
122-
shortcut={{ modifiers: ["mod"], key: "/", enabledOnInputElements: true }}
123-
hideShortcutKey
124-
data-modal-override-open-class-ask-ai="true"
125-
onClick={() => openAskAI()}
126-
>
127-
<AISparkleIcon className="size-5" />
128-
</Button>
118+
<motion.div layout="position" transition={{ duration: 0.2, ease: "easeInOut" }}>
119+
<TooltipProvider disableHoverableContent>
120+
<Tooltip>
121+
<div className={isCollapsed ? "w-full" : "inline-flex"}>
122+
<TooltipTrigger asChild>
123+
<Button
124+
variant="small-menu-item"
125+
data-action="ask-ai"
126+
shortcut={{ modifiers: ["mod"], key: "/", enabledOnInputElements: true }}
127+
hideShortcutKey
128+
data-modal-override-open-class-ask-ai="true"
129+
onClick={() => openAskAI()}
130+
className={isCollapsed ? "w-full justify-center" : ""}
131+
>
132+
<AISparkleIcon className="size-5" />
133+
</Button>
134+
</TooltipTrigger>
129135
</div>
130-
</TooltipTrigger>
131-
<TooltipContent side="top" className="flex items-center gap-1 py-1.5 pl-2.5 pr-2 text-xs">
132-
Ask AI
133-
<ShortcutKey shortcut={{ modifiers: ["mod"], key: "/" }} variant="medium/bright" />
134-
</TooltipContent>
135-
</Tooltip>
136-
</TooltipProvider>
136+
<TooltipContent
137+
side={isCollapsed ? "right" : "top"}
138+
sideOffset={isCollapsed ? 8 : 4}
139+
className="flex items-center gap-1 py-1.5 pl-2.5 pr-2 text-xs"
140+
>
141+
Ask AI
142+
<span className="flex items-center">
143+
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
144+
<ShortcutKey shortcut={{ key: "/" }} variant="medium/bright" />
145+
</span>
146+
</TooltipContent>
147+
</Tooltip>
148+
</TooltipProvider>
149+
</motion.div>
137150
<AskAIDialog
138151
initialQuery={initialQuery}
139152
isOpen={isOpen}

apps/webapp/app/components/Shortcuts.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
11
import { Keyboard } from "lucide-react";
2+
import { useState } from "react";
3+
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
4+
import { Button } from "./primitives/Buttons";
25
import { Header3 } from "./primitives/Headers";
36
import { Paragraph } from "./primitives/Paragraph";
47
import {
58
Sheet,
69
SheetContent,
7-
SheetDescription,
810
SheetHeader,
911
SheetTitle,
10-
SheetTrigger,
12+
SheetTrigger
1113
} from "./primitives/SheetV3";
1214
import { ShortcutKey } from "./primitives/ShortcutKey";
13-
import { Button } from "./primitives/Buttons";
14-
import { useState } from "react";
15-
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
1615

1716
export function Shortcuts() {
1817
return (
@@ -26,8 +25,8 @@ export function Shortcuts() {
2625
fullWidth
2726
textAlignLeft
2827
shortcut={{ modifiers: ["shift"], key: "?", enabled: false }}
29-
className="gap-x-0 pl-0.5"
30-
iconSpacing="gap-x-0.5"
28+
className="gap-x-0 pl-1.5"
29+
iconSpacing="gap-x-1.5"
3130
>
3231
Shortcuts
3332
</Button>
@@ -82,6 +81,10 @@ function ShortcutContent() {
8281
<Shortcut name="Filter">
8382
<ShortcutKey shortcut={{ key: "f" }} variant="medium/bright" />
8483
</Shortcut>
84+
<Shortcut name="Toggle side menu">
85+
<ShortcutKey shortcut={{ modifiers: ["mod"]}} variant="medium/bright" />
86+
<ShortcutKey shortcut={{ key: "b" }} variant="medium/bright" />
87+
</Shortcut>
8588
<Shortcut name="Select filter">
8689
<ShortcutKey shortcut={{ key: "1" }} variant="medium/bright" />
8790
<Paragraph variant="small" className="ml-1.5">

apps/webapp/app/components/environments/EnvironmentLabel.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,13 @@ export function EnvironmentLabel({
8080
className,
8181
tooltipSideOffset = 34,
8282
tooltipSide = "right",
83+
disableTooltip = false,
8384
}: {
8485
environment: Environment;
8586
className?: string;
8687
tooltipSideOffset?: number;
8788
tooltipSide?: "top" | "right" | "bottom" | "left";
89+
disableTooltip?: boolean;
8890
}) {
8991
const spanRef = useRef<HTMLSpanElement>(null);
9092
const [isTruncated, setIsTruncated] = useState(false);
@@ -117,7 +119,7 @@ export function EnvironmentLabel({
117119
</span>
118120
);
119121

120-
if (isTruncated) {
122+
if (isTruncated && !disableTooltip) {
121123
return (
122124
<SimpleTooltip
123125
asChild

apps/webapp/app/components/navigation/AccountSideMenu.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@ import {
88
personalAccessTokensPath,
99
rootPath,
1010
} from "~/utils/pathBuilder";
11+
import { AskAI } from "../AskAI";
1112
import { LinkButton } from "../primitives/Buttons";
13+
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
1214
import { SideMenuHeader } from "./SideMenuHeader";
1315
import { SideMenuItem } from "./SideMenuItem";
14-
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
1516

1617
export function AccountSideMenu({ user }: { user: User }) {
1718
return (
@@ -55,8 +56,9 @@ export function AccountSideMenu({ user }: { user: User }) {
5556
data-action="security"
5657
/>
5758
</div>
58-
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
59+
<div className="flex w-full items-center justify-between border-t border-grid-bright p-1">
5960
<HelpAndFeedback />
61+
<AskAI />
6062
</div>
6163
</div>
6264
);

apps/webapp/app/components/navigation/EnvironmentSelector.tsx

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ChevronRightIcon, Cog8ToothIcon } from "@heroicons/react/20/solid";
2+
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
23
import { useNavigation } from "@remix-run/react";
34
import { useEffect, useRef, useState } from "react";
45
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
@@ -9,19 +10,19 @@ import { useOrganization, type MatchedOrganization } from "~/hooks/useOrganizati
910
import { useProject } from "~/hooks/useProject";
1011
import { cn } from "~/utils/cn";
1112
import { branchesPath, docsPath, v3BillingPath } from "~/utils/pathBuilder";
12-
import { EnvironmentCombo } from "../environments/EnvironmentLabel";
13+
import { EnvironmentCombo, EnvironmentIcon, EnvironmentLabel, environmentFullTitle } from "../environments/EnvironmentLabel";
1314
import { ButtonContent } from "../primitives/Buttons";
1415
import { Header2 } from "../primitives/Headers";
1516
import { Paragraph } from "../primitives/Paragraph";
1617
import {
1718
Popover,
18-
PopoverArrowTrigger,
1919
PopoverContent,
2020
PopoverMenuItem,
2121
PopoverSectionHeader,
2222
PopoverTrigger,
2323
} from "../primitives/Popover";
2424
import { TextLink } from "../primitives/TextLink";
25+
import { SimpleTooltip } from "../primitives/Tooltip";
2526
import { V4Badge } from "../V4Badge";
2627
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
2728
import { Badge } from "../primitives/Badge";
@@ -31,11 +32,13 @@ export function EnvironmentSelector({
3132
project,
3233
environment,
3334
className,
35+
isCollapsed = false,
3436
}: {
3537
organization: MatchedOrganization;
3638
project: SideMenuProject;
3739
environment: SideMenuEnvironment;
3840
className?: string;
41+
isCollapsed?: boolean;
3942
}) {
4043
const { isManagedCloud } = useFeatures();
4144
const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -50,16 +53,48 @@ export function EnvironmentSelector({
5053

5154
return (
5255
<Popover onOpenChange={(open) => setIsMenuOpen(open)} open={isMenuOpen}>
53-
<PopoverArrowTrigger
54-
isOpen={isMenuOpen}
55-
overflowHidden
56-
fullWidth
57-
className={cn("h-7 overflow-hidden py-1 pl-1.5", className)}
58-
>
59-
<EnvironmentCombo environment={environment} className="w-full text-2sm" />
60-
</PopoverArrowTrigger>
56+
<SimpleTooltip
57+
button={
58+
<PopoverTrigger
59+
className={cn(
60+
"group flex h-8 items-center rounded pl-[0.4375rem] transition-colors hover:bg-charcoal-750",
61+
isCollapsed ? "justify-center pr-0.5" : "justify-between pr-1",
62+
className
63+
)}
64+
>
65+
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
66+
<EnvironmentIcon environment={environment} className="size-5 shrink-0" />
67+
<span
68+
className={cn(
69+
"flex min-w-0 items-center overflow-hidden transition-all duration-200",
70+
isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"
71+
)}
72+
>
73+
<EnvironmentLabel environment={environment} className="text-2sm" disableTooltip />
74+
</span>
75+
</span>
76+
<span
77+
className={cn(
78+
"overflow-hidden transition-all duration-200",
79+
isCollapsed ? "max-w-0 opacity-0" : "max-w-[16px] opacity-100"
80+
)}
81+
>
82+
<DropdownIcon className="size-4 min-w-4 text-text-dimmed transition group-hover:text-text-bright" />
83+
</span>
84+
</PopoverTrigger>
85+
}
86+
content={environmentFullTitle(environment)}
87+
side="right"
88+
sideOffset={8}
89+
hidden={!isCollapsed}
90+
buttonClassName="!h-8"
91+
asChild
92+
disableHoverableContent
93+
/>
6194
<PopoverContent
6295
className="min-w-[14rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
96+
side={isCollapsed ? "right" : "bottom"}
97+
sideOffset={isCollapsed ? 8 : 4}
6398
align="start"
6499
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
65100
>

0 commit comments

Comments
 (0)