Skip to content

Commit 0b29194

Browse files
authored
feat(webapp): redesign the side menu project and organization menus (#4066)
Redesign of the main side menu: separates Projects and Accounts from the Organization menu and makes the menu resizable. **Main changes** - **Organization & Account menus**: the top-left is now a dedicated organization menu (Settings, Usage, Billing, Team, SSO, integrations), with a separate account menu beside it (Profile, PATs, Security, Logout). - **Project switcher**: a new Project section above the Environment selector. - **Resizable side menu**: drag the right edge to set a custom width (saved per user), or click the edge to collapse/expand. - **Environment selector**: reworked to match the Project menu, including dev-branch handling. - **Account Profile page**: redesigned into the Security page's row-and-divider layout. Preview URL: https://samejr-org-menu-update.triggerlabs.dev/ https://github.com/user-attachments/assets/9b199576-6037-4ea6-9bdb-3ee15265b8c2
1 parent e2d3b83 commit 0b29194

29 files changed

Lines changed: 2094 additions & 862 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Refreshed the side menu: separate organization and account menus, a new project switcher, and the menu is now resizable by dragging its edge. The account Profile page has also been redesigned.
Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export function AvatarCircleIcon({ className }: { className?: string }) {
1+
function AvatarCircle({ className, strokeWidth }: { className?: string; strokeWidth: number }) {
22
return (
33
<svg
44
className={className}
@@ -8,13 +8,28 @@ export function AvatarCircleIcon({ className }: { className?: string }) {
88
fill="none"
99
xmlns="http://www.w3.org/2000/svg"
1010
>
11-
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2" />
12-
<circle cx="12" cy="9.5" r="2.5" stroke="currentColor" strokeWidth="2" />
11+
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth={strokeWidth} />
12+
<circle cx="12" cy="9.5" r="2.5" stroke="currentColor" strokeWidth={strokeWidth} />
1313
<path
1414
d="M6 19C7.00156 16.6478 9.32233 15 12.0254 15C14.6837 15 16.9724 16.5938 18 18.884"
1515
stroke="currentColor"
16-
strokeWidth="2"
16+
strokeWidth={strokeWidth}
1717
/>
1818
</svg>
1919
);
2020
}
21+
22+
/** User avatar placeholder with a 2px stroke (the default). */
23+
export function AvatarCircleIcon({ className }: { className?: string }) {
24+
return <AvatarCircle className={className} strokeWidth={2} />;
25+
}
26+
27+
/** Thinner 1.5px-stroke variant of {@link AvatarCircleIcon}. */
28+
export function AvatarCircleIconThin({ className }: { className?: string }) {
29+
return <AvatarCircle className={className} strokeWidth={1.5} />;
30+
}
31+
32+
/** Thinnest 1.25px-stroke variant of {@link AvatarCircleIcon}. */
33+
export function AvatarCircleIconExtraThin({ className }: { className?: string }) {
34+
return <AvatarCircle className={className} strokeWidth={1.25} />;
35+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export function ChainLinkIcon({ className }: { className?: string }) {
2+
return (
3+
<svg
4+
className={className}
5+
width="24"
6+
height="24"
7+
viewBox="0 0 24 24"
8+
fill="none"
9+
xmlns="http://www.w3.org/2000/svg"
10+
>
11+
<path
12+
d="M10 19.0004L9.82843 19.1719C8.26634 20.734 5.73368 20.734 4.17158 19.1719L3.82843 18.8288C2.26634 17.2667 2.26633 14.734 3.82843 13.1719L7.17158 9.8288C8.73368 8.2667 11.2663 8.2667 12.8284 9.8288L13.1716 10.1719C13.8252 10.8256 14.2053 11.6491 14.312 12.5004"
13+
stroke="currentColor"
14+
strokeWidth="2"
15+
strokeLinecap="round"
16+
strokeLinejoin="round"
17+
/>
18+
<path
19+
d="M9.68799 12.5004C9.79463 13.3516 10.1748 14.1752 10.8284 14.8288L11.1715 15.1719C12.7336 16.734 15.2663 16.734 16.8284 15.1719L20.1715 11.8288C21.7336 10.2667 21.7336 7.73404 20.1715 6.17194L19.8284 5.8288C18.2663 4.2667 15.7336 4.2667 14.1715 5.8288L14 6.00037"
20+
stroke="currentColor"
21+
strokeWidth="2"
22+
strokeLinecap="round"
23+
strokeLinejoin="round"
24+
/>
25+
</svg>
26+
);
27+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export function LeftSideMenuCollapsedIcon({ className }: { className?: string }) {
2+
return (
3+
<svg
4+
className={className}
5+
width="24"
6+
height="24"
7+
viewBox="0 0 24 24"
8+
fill="none"
9+
xmlns="http://www.w3.org/2000/svg"
10+
>
11+
<rect x="4" y="4" width="16" height="16" rx="3" stroke="currentColor" strokeWidth="2" />
12+
<rect x="6" y="6" width="2" height="12" rx="1" fill="currentColor" />
13+
<path
14+
d="M12 14.5L14.5 12L12 9.5"
15+
stroke="currentColor"
16+
strokeWidth="2"
17+
strokeLinecap="round"
18+
strokeLinejoin="round"
19+
/>
20+
</svg>
21+
);
22+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { motion } from "framer-motion";
2+
import { useState } from "react";
3+
4+
export function LeftSideMenuIcon({
5+
className,
6+
hovered: controlledHovered,
7+
}: {
8+
className?: string;
9+
/** Drives the animation when provided (e.g. parent hover); otherwise the icon uses its own hover. */
10+
hovered?: boolean;
11+
}) {
12+
const [internalHovered, setInternalHovered] = useState(false);
13+
const isControlled = controlledHovered !== undefined;
14+
const hovered = isControlled ? controlledHovered : internalHovered;
15+
16+
return (
17+
<svg
18+
className={className}
19+
width="24"
20+
height="24"
21+
viewBox="0 0 24 24"
22+
fill="none"
23+
xmlns="http://www.w3.org/2000/svg"
24+
onMouseEnter={isControlled ? undefined : () => setInternalHovered(true)}
25+
onMouseLeave={isControlled ? undefined : () => setInternalHovered(false)}
26+
>
27+
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" />
28+
{/* Animate a transform (scaleX), not the SVG `width` attr — framer snaps the first animation
29+
of an idle SVG geometry attribute. Left origin collapses the panel right-to-left. */}
30+
<motion.rect
31+
x="6"
32+
y="6"
33+
width="5"
34+
height="12"
35+
rx="1"
36+
fill="currentColor"
37+
initial={false}
38+
style={{ originX: 0 }}
39+
animate={{ scaleX: hovered ? 0.2 : 1 }}
40+
transition={{ duration: 0.3, ease: "easeInOut" }}
41+
/>
42+
</svg>
43+
);
44+
}

apps/webapp/app/components/AskAI.tsx

Lines changed: 101 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@ import { useSearchParams } from "@remix-run/react";
1111
import DOMPurify from "dompurify";
1212
import { motion } from "framer-motion";
1313
import { marked } from "marked";
14-
import { useCallback, useEffect, useRef, useState } from "react";
14+
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
1515
import { useTypedRouteLoaderData } from "remix-typedjson";
1616
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
1717
import { SparkleListIcon } from "~/assets/icons/SparkleListIcon";
1818
import { useFeatures } from "~/hooks/useFeatures";
19+
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
1920
import { type loader } from "~/root";
2021
import { Button } from "./primitives/Buttons";
2122
import { Callout } from "./primitives/Callout";
@@ -38,6 +39,104 @@ function useKapaWebsiteId() {
3839
return routeMatch?.kapa.websiteId;
3940
}
4041

42+
/** Open/close state for the Ask AI dialog, including the `?aiHelp=` deep-link handling. */
43+
function useAskAIState() {
44+
const [isOpen, setIsOpen] = useState(false);
45+
const [initialQuery, setInitialQuery] = useState<string | undefined>();
46+
const [searchParams, setSearchParams] = useSearchParams();
47+
48+
const openAskAI = useCallback((question?: string) => {
49+
if (question) {
50+
setInitialQuery(question);
51+
} else {
52+
setInitialQuery(undefined);
53+
}
54+
setIsOpen(true);
55+
}, []);
56+
57+
const closeAskAI = useCallback(() => {
58+
setIsOpen(false);
59+
setInitialQuery(undefined);
60+
}, []);
61+
62+
// Handle URL param functionality
63+
useEffect(() => {
64+
const aiHelp = searchParams.get("aiHelp");
65+
if (aiHelp) {
66+
// Delay to avoid hCaptcha bot detection
67+
window.setTimeout(() => openAskAI(aiHelp), 1000);
68+
69+
// Clone instead of mutating in place
70+
const next = new URLSearchParams(searchParams);
71+
next.delete("aiHelp");
72+
setSearchParams(next);
73+
}
74+
}, [searchParams, openAskAI]);
75+
76+
return { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI };
77+
}
78+
79+
/**
80+
* Hosts Ask AI (Kapa provider, ⌘I shortcut, dialog) for a menu that renders its own trigger. Wrap
81+
* it around the popover, not inside, so the dialog and shortcut survive the popover closing.
82+
* `children` receives the open function, or undefined when Ask AI is unavailable (self-hosted, no
83+
* Kapa website id, or SSR).
84+
*/
85+
export function AskAIRoot({
86+
children,
87+
}: {
88+
children: (openAskAI: (() => void) | undefined) => ReactNode;
89+
}) {
90+
const { isManagedCloud } = useFeatures();
91+
const websiteId = useKapaWebsiteId();
92+
93+
if (!isManagedCloud || !websiteId) {
94+
return <>{children(undefined)}</>;
95+
}
96+
97+
return (
98+
<ClientOnly fallback={<>{children(undefined)}</>}>
99+
{() => <AskAIRootProvider websiteId={websiteId}>{children}</AskAIRootProvider>}
100+
</ClientOnly>
101+
);
102+
}
103+
104+
function AskAIRootProvider({
105+
websiteId,
106+
children,
107+
}: {
108+
websiteId: string;
109+
children: (openAskAI: () => void) => ReactNode;
110+
}) {
111+
const { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI } = useAskAIState();
112+
113+
useShortcutKeys({
114+
shortcut: { modifiers: ["mod"], key: "i", enabledOnInputElements: true },
115+
action: () => openAskAI(),
116+
});
117+
118+
return (
119+
<KapaProvider
120+
integrationId={websiteId}
121+
callbacks={{
122+
askAI: {
123+
onQuerySubmit: () => openAskAI(),
124+
onAnswerGenerationCompleted: () => openAskAI(),
125+
},
126+
}}
127+
botProtectionMechanism="hcaptcha"
128+
>
129+
{children(() => openAskAI())}
130+
<AskAIDialog
131+
initialQuery={initialQuery}
132+
isOpen={isOpen}
133+
onOpenChange={setIsOpen}
134+
closeAskAI={closeAskAI}
135+
/>
136+
</KapaProvider>
137+
);
138+
}
139+
41140
export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) {
42141
const { isManagedCloud } = useFeatures();
43142
const websiteId = useKapaWebsiteId();
@@ -72,37 +171,7 @@ type AskAIProviderProps = {
72171
};
73172

74173
function AskAIProvider({ websiteId, isCollapsed = false }: AskAIProviderProps) {
75-
const [isOpen, setIsOpen] = useState(false);
76-
const [initialQuery, setInitialQuery] = useState<string | undefined>();
77-
const [searchParams, setSearchParams] = useSearchParams();
78-
79-
const openAskAI = useCallback((question?: string) => {
80-
if (question) {
81-
setInitialQuery(question);
82-
} else {
83-
setInitialQuery(undefined);
84-
}
85-
setIsOpen(true);
86-
}, []);
87-
88-
const closeAskAI = useCallback(() => {
89-
setIsOpen(false);
90-
setInitialQuery(undefined);
91-
}, []);
92-
93-
// Handle URL param functionality
94-
useEffect(() => {
95-
const aiHelp = searchParams.get("aiHelp");
96-
if (aiHelp) {
97-
// Delay to avoid hCaptcha bot detection
98-
window.setTimeout(() => openAskAI(aiHelp), 1000);
99-
100-
// Clone instead of mutating in place
101-
const next = new URLSearchParams(searchParams);
102-
next.delete("aiHelp");
103-
setSearchParams(next);
104-
}
105-
}, [searchParams, openAskAI]);
174+
const { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI } = useAskAIState();
106175

107176
return (
108177
<KapaProvider
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { useNavigate, useSubmit } from "@remix-run/react";
2+
import { useEffect } from "react";
3+
import { useIsImpersonating } from "~/hooks/useOrganizations";
4+
import { useOptionalUser } from "~/hooks/useUser";
5+
import { adminPath } from "~/utils/pathBuilder";
6+
7+
/** App-wide keyboard shortcuts, mounted once at the root so they work everywhere. Renders nothing. */
8+
export function GlobalShortcuts() {
9+
const user = useOptionalUser();
10+
const isImpersonating = useIsImpersonating();
11+
const navigate = useNavigate();
12+
const submit = useSubmit();
13+
14+
const isAdmin = Boolean(user?.admin) || isImpersonating;
15+
16+
useEffect(() => {
17+
if (!isAdmin) return;
18+
19+
const onKeyDown = (event: KeyboardEvent) => {
20+
// Admin escape hatch: Cmd+Option+A (Ctrl+Alt+A on Windows) opens the admin dashboard, or stops
21+
// impersonating. Avoids Escape — Chrome/macOS never delivers a keydown for Escape+modifier (why
22+
// the old Cmd+Esc did nothing). Matched on `event.code`, not `event.key`, because Option makes
23+
// "A" report "å" (so a raw listener, not the `event.key`-based useShortcutKeys hook).
24+
if (event.code !== "KeyA" || !event.altKey || !(event.metaKey || event.ctrlKey)) {
25+
return;
26+
}
27+
event.preventDefault();
28+
if (isImpersonating) {
29+
submit(null, { action: "/resources/impersonation", method: "delete" });
30+
} else {
31+
navigate(adminPath());
32+
}
33+
};
34+
35+
document.addEventListener("keydown", onKeyDown);
36+
return () => document.removeEventListener("keydown", onKeyDown);
37+
}, [isAdmin, isImpersonating, navigate, submit]);
38+
39+
return null;
40+
}

apps/webapp/app/components/Shortcuts.tsx

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { KeyboardIcon } from "~/assets/icons/KeyboardIcon";
22
import { useState } from "react";
33
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
4-
import { Button } from "./primitives/Buttons";
54
import { Header3 } from "./primitives/Headers";
5+
import { SideMenuItemButton } from "./navigation/SideMenuItem";
66
import { Paragraph } from "./primitives/Paragraph";
77
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "./primitives/SheetV3";
88
import { ShortcutKey } from "./primitives/ShortcutKey";
@@ -11,19 +11,12 @@ export function Shortcuts() {
1111
return (
1212
<Sheet>
1313
<SheetTrigger asChild>
14-
<Button
15-
variant="small-menu-item"
16-
LeadingIcon={KeyboardIcon}
17-
leadingIconClassName="text-text-dimmed group-hover/button:text-text-bright"
14+
<SideMenuItemButton
15+
icon={KeyboardIcon}
16+
name="Shortcuts"
1817
data-action="shortcuts"
19-
fullWidth
20-
textAlignLeft
21-
shortcut={{ modifiers: ["shift"], key: "?", enabled: false }}
22-
className="gap-x-0 pl-1.5"
23-
iconSpacing="gap-x-1.5"
24-
>
25-
Shortcuts
26-
</Button>
18+
trailing={<ShortcutKey shortcut={{ modifiers: ["shift"], key: "?" }} variant="medium" />}
19+
/>
2720
</SheetTrigger>
2821
<ShortcutContent />
2922
</Sheet>

0 commit comments

Comments
 (0)