From c6138e38ae6850b594206925a2197476cd8a781c Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 28 Jun 2026 20:15:52 +0100 Subject: [PATCH 01/29] feat(webapp): split the side menu project and organization menus The top-left menu now represents the organization: it shows the org name and avatar and opens a flat list of org-level pages (Settings, Usage, Billing, Billing alerts, Team, an Integrations submenu, Switch organization, Account, Logout). Projects move into a dedicated "Project" section in the menu body: a project picker dropdown with the environment selector directly beneath it. --- .../side-menu-project-and-org-menus.md | 20 + .../navigation/EnvironmentSelector.tsx | 1 + .../app/components/navigation/SideMenu.tsx | 381 +++++++++++++----- .../_app.orgs.$organizationSlug/route.tsx | 39 +- 4 files changed, 328 insertions(+), 113 deletions(-) create mode 100644 .server-changes/side-menu-project-and-org-menus.md diff --git a/.server-changes/side-menu-project-and-org-menus.md b/.server-changes/side-menu-project-and-org-menus.md new file mode 100644 index 0000000000..d64f0fd95e --- /dev/null +++ b/.server-changes/side-menu-project-and-org-menus.md @@ -0,0 +1,20 @@ +--- +area: webapp +type: improvement +--- + +Restructure the side menu's top-left and project/organization navigation: + +- Add a new "Project" section above the "Environment" section with a popover + that lists the org's projects (folder icon + checkmark for the selected one) + and a "New project" item at the bottom. +- The top-left menu now shows the organization (avatar + org name, no + project/diagonal divider) and its popover is a clean list of org-level items + (Settings, Usage, Billing with plan badge, Billing alerts, Team, Private + connections, Roles, SSO, Vercel integration, Slack integration, Switch + organization, then Account and Logout) using the same icons and links as the + organization settings side menu. + +The org loader now exposes whether the RBAC and SSO plugins are installed so the +side menu can gate the Roles and SSO items the same way the settings side menu +does. diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index 40174acfe0..f1d340b35e 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -111,6 +111,7 @@ export function EnvironmentSelector({ align="start" style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }} > +
{project.environments .filter((env) => env.parentEnvironmentId === null) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 619d2477a4..c59b9eb9ec 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -2,13 +2,12 @@ import { ArrowTopRightOnSquareIcon, ChevronRightIcon, ExclamationTriangleIcon, - PencilSquareIcon, } from "@heroicons/react/24/outline"; -import { Link, useFetcher, useNavigation } from "@remix-run/react"; +import { LinkIcon } from "@heroicons/react/24/solid"; +import { useFetcher, useNavigation } from "@remix-run/react"; import { BugIcon } from "~/assets/icons/BugIcon"; import { LayoutGroup, motion } from "framer-motion"; import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; -import simplur from "simplur"; import { AIChatIcon } from "~/assets/icons/AIChatIcon"; import { AIPenIcon } from "~/assets/icons/AIPenIcon"; import { ArrowLeftRightIcon } from "~/assets/icons/ArrowLeftRightIcon"; @@ -41,6 +40,12 @@ import { TasksIcon } from "~/assets/icons/TasksIcon"; import { BellIcon } from "~/assets/icons/BellIcon"; import { UsageIcon } from "~/assets/icons/UsageIcon"; import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; +import { CreditCardIcon } from "~/assets/icons/CreditCardIcon"; +import { UserGroupIcon } from "~/assets/icons/UserGroupIcon"; +import { RolesIcon } from "~/assets/icons/RolesIcon"; +import { PadlockIcon } from "~/assets/icons/PadlockIcon"; +import { SlackIcon } from "~/assets/icons/SlackIcon"; +import { VercelLogo } from "~/components/integrations/VercelLogo"; import { Avatar } from "~/components/primitives/Avatar"; import { type MatchedEnvironment } from "~/hooks/useEnvironment"; import { useFeatureFlags } from "~/hooks/useFeatureFlags"; @@ -48,9 +53,14 @@ import { useFeatures } from "~/hooks/useFeatures"; import { type MatchedOrganization } from "~/hooks/useOrganizations"; import { type MatchedProject } from "~/hooks/useProject"; import { useShortcutKeys } from "~/hooks/useShortcutKeys"; +import { useShowSelfServe } from "~/hooks/useShowSelfServe"; import { useHasAdminAccess } from "~/hooks/useUser"; import { type UserWithDashboardPreferences } from "~/models/user.server"; -import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route"; +import { + useCurrentPlan, + useIsUsingRbacPlugin, + useIsUsingSsoPlugin, +} from "~/routes/_app.orgs.$organizationSlug/route"; import { type FeedbackType } from "~/routes/resources.feedback"; import { IncidentStatusPanel, useIncidentStatus } from "~/routes/resources.incidents"; import { NotificationPanel } from "./NotificationPanel"; @@ -65,13 +75,19 @@ import { newOrganizationPath, newProjectPath, organizationPath, + organizationRolesPath, organizationSettingsPath, + organizationSlackIntegrationPath, + organizationSsoPath, organizationTeamPath, + organizationVercelIntegrationPath, queryPath, regionsPath, v3ApiKeysPath, v3BatchesPath, + v3BillingLimitsPath, v3BillingPath, + v3PrivateConnectionsPath, v3DashboardsLandingPath, v3BulkActionsPath, v3DeploymentsPath, @@ -99,9 +115,9 @@ import { ImpersonationBanner } from "../ImpersonationBanner"; import { Button, ButtonContent, LinkButton } from "../primitives/Buttons"; import { Dialog, DialogTrigger } from "../primitives/Dialog"; import { Paragraph } from "../primitives/Paragraph"; +import { Badge } from "../primitives/Badge"; import { Popover, PopoverContent, PopoverMenuItem, PopoverTrigger } from "../primitives/Popover"; import { ShortcutKey } from "../primitives/ShortcutKey"; -import { TextLink } from "../primitives/TextLink"; import { SimpleTooltip, Tooltip, @@ -297,11 +313,9 @@ export function SideMenu({ )} >
-
@@ -341,10 +355,12 @@ export function SideMenu({ >
- +
{ setOrgMenuOpen(false); @@ -858,9 +870,8 @@ function ProjectSelector({ isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100" )} > - - {project.name ?? "Select a project"} + {organization.title} @@ -874,7 +885,7 @@ function ProjectSelector({ } - content={`${organization.title} / ${project.name ?? "Select a project"}`} + content={organization.title} side="right" sideOffset={8} hidden={!isCollapsed} @@ -889,83 +900,73 @@ function ProjectSelector({ align="start" style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }} > -
-
- - -
- -
- -
- {organization.title} -
- {plan && ( - - {plan} plan - - )} - {simplur`${organization.membersCount} member[|s]`} -
-
-
-
- - - Settings - - {isManagedCloud && ( - - - Usage - - )} -
-
- {organization.projects.map((p) => { - const isSelected = p.id === project.id; - return ( - - {p.name} -
- } - isSelected={isSelected} - icon={isSelected ? FolderOpenIcon : FolderClosedIcon} - leadingIconClassName="text-indigo-500" - /> - ); - })} - -
-
+ + {isManagedCloud && ( + + )} + {isManagedCloud && ( + + Billing + {isPaying && planTitle ? {planTitle} : null} +
+ } + icon={CreditCardIcon} + leadingIconClassName="text-text-dimmed" + /> + )} + {isManagedCloud && showSelfServe && ( + + )} + + {featureFlags.hasPrivateConnections && ( + + )} + {isUsingRbacPlugin && ( + + )} + {isUsingSsoPlugin && ( + + )} + {organizations.length > 1 ? ( ) : ( @@ -999,6 +1000,98 @@ function ProjectSelector({ ); } +function ProjectSelector({ + project, + organization, + isCollapsed = false, + className, +}: { + project: SideMenuProject; + organization: MatchedOrganization; + isCollapsed?: boolean; + className?: string; +}) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const navigation = useNavigation(); + + useEffect(() => { + setIsMenuOpen(false); + }, [navigation.location?.pathname]); + + return ( + setIsMenuOpen(open)} open={isMenuOpen}> + + + + + + {project.name ?? "Select a project"} + + + + + + + + } + content={project.name ?? "Select a project"} + side="right" + sideOffset={8} + hidden={!isCollapsed} + buttonClassName="!h-8" + asChild + disableHoverableContent + /> + +
+ {organization.projects.map((p) => { + const isSelected = p.id === project.id; + return ( + + {p.name} +
+ } + isSelected={isSelected} + icon={isSelected ? FolderOpenIcon : FolderClosedIcon} + leadingIconClassName="text-indigo-500" + /> + ); + })} + +
+ + + ); +} + function SwitchOrganizations({ organizations, organization, @@ -1090,18 +1183,82 @@ function SwitchOrganizations({ ); } -function SelectorDivider() { +function Integrations({ organization }: { organization: MatchedOrganization }) { + const navigation = useNavigation(); + const [isMenuOpen, setMenuOpen] = useState(false); + const timeoutRef = useRef(null); + + // Clear timeout on unmount + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + useEffect(() => { + setMenuOpen(false); + }, [navigation.location?.pathname]); + + const handleMouseEnter = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + setMenuOpen(true); + }; + + const handleMouseLeave = () => { + // Small delay before closing to allow moving to the content + timeoutRef.current = setTimeout(() => { + setMenuOpen(false); + }, 150); + }; + return ( - - - + setMenuOpen(open)} open={isMenuOpen}> +
+ + + Integrations + + + +
+ + +
+
+
+
); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx index ebed894411..fc1a666175 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx @@ -11,6 +11,7 @@ import { RegionsPresenter, type Region } from "~/presenters/v3/RegionsPresenter. import { getImpersonationId } from "~/services/impersonation.server"; import { getCachedUsage, getBillingLimit, getCurrentPlan } from "~/services/platform.v3.server"; import { rbac } from "~/services/rbac.server"; +import { ssoController } from "~/services/sso.server"; import { canManageBilling } from "~/services/routeBuilders/permissions.server"; import { requireUser } from "~/services/session.server"; import { telemetry } from "~/services/telemetry.server"; @@ -33,6 +34,26 @@ export function useCurrentPlan(matches?: UIMatch[]) { return data?.currentPlan; } +/** Whether the optional RBAC plugin is installed (gates the Roles UI). */ +export function useIsUsingRbacPlugin(matches?: UIMatch[]) { + const data = useTypedMatchesData({ + id: "routes/_app.orgs.$organizationSlug", + matches, + }); + + return data?.isUsingRbacPlugin ?? false; +} + +/** Whether the optional SSO plugin is installed (gates the SSO UI). */ +export function useIsUsingSsoPlugin(matches?: UIMatch[]) { + const data = useTypedMatchesData({ + id: "routes/_app.orgs.$organizationSlug", + matches, + }); + + return data?.isUsingSsoPlugin ?? false; +} + export const shouldRevalidate: ShouldRevalidateFunction = (params) => { const { currentParams, nextParams } = params; @@ -98,7 +119,16 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const shouldLoadRegions = !!projectParam && !!environment && environment.type !== "DEVELOPMENT"; - const [sessionAuth, plan, usage, billingLimit, customDashboards, regions] = await Promise.all([ + const [ + sessionAuth, + plan, + usage, + billingLimit, + customDashboards, + regions, + isUsingRbacPlugin, + isUsingSsoPlugin, + ] = await Promise.all([ rbac .authenticateSession(request, { userId: user.id, @@ -123,6 +153,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { .then(({ regions }) => regions) .catch(() => [] as Region[]) : Promise.resolve([] as Region[]), + // Resolve which optional plugins are installed so the side menu can gate the + // Roles (RBAC) and SSO items the same way the org settings side menu does. + // Both calls are cheap and cached after the first resolution. + rbac.isUsingPlugin().catch(() => false), + ssoController.isUsingPlugin().catch(() => false), ]); const userCanManageBilling = sessionAuth.ok ? canManageBilling(sessionAuth.ability) : false; @@ -182,6 +217,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }, widgetLimitPerDashboard, canManageBilling: userCanManageBilling, + isUsingRbacPlugin, + isUsingSsoPlugin, }); }; From f2e8a0ea353a935043578ef49c87a052d3b3f29e Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 28 Jun 2026 20:54:01 +0100 Subject: [PATCH 02/29] feat(webapp): dim project menu trigger and add environment tooltip The Project menu trigger now matches the other side menu items: its label and folder icon are dimmed by default and brighten on hover, instead of staying highlighted. The environment selector shows a "[name] environment" tooltip on hover. It opens instantly when the side menu is collapsed and after a short delay when expanded. --- apps/webapp/app/components/navigation/EnvironmentSelector.tsx | 4 ++-- apps/webapp/app/components/navigation/SideMenu.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index f1d340b35e..e3feffb592 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -96,10 +96,10 @@ export function EnvironmentSelector({ } - content={environmentFullTitle(environment)} + content={`${environmentFullTitle(environment)} environment`} side="right" sideOffset={8} - hidden={!isCollapsed} + delayDuration={isCollapsed ? 0 : 500} buttonClassName="!h-8" asChild disableHoverableContent diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index c59b9eb9ec..df3cae1409 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -1030,14 +1030,14 @@ function ProjectSelector({ )} > - + - + {project.name ?? "Select a project"} From 440023b744cf2c62bb8632655851a56d027c3f64 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 28 Jun 2026 21:37:51 +0100 Subject: [PATCH 03/29] feat(webapp): connect the environment selector to the project menu Add an end tree-connector to the left of the environment selector and remove the gap between it and the project dropdown, so the environment reads as nested under the project in the side menu. --- .../navigation/EnvironmentSelector.tsx | 19 +++++ .../app/components/navigation/SideMenu.tsx | 81 ++++++++++--------- 2 files changed, 61 insertions(+), 39 deletions(-) diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index e3feffb592..6701c07213 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -41,12 +41,16 @@ export function EnvironmentSelector({ environment, className, isCollapsed = false, + showConnector = false, }: { organization: MatchedOrganization; project: SideMenuProject; environment: SideMenuEnvironment; className?: string; isCollapsed?: boolean; + /** Show an end tree-connector to the left of the icon so the selector reads + * as connected to the Project menu above it. Only used in the side menu. */ + showConnector?: boolean; }) { const { isManagedCloud } = useFeatures(); const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -71,6 +75,21 @@ export function EnvironmentSelector({ className )} > + {showConnector && + !isCollapsed && ( + // End tree-connector sized to the full button height (viewBox matches the + // 20x32 box) so the vertical line reaches the button's top edge and meets the + // Project button above, with the corner aligned to the environment icon's center. + + + + + )}
- -
- + - {environment.type === "DEVELOPMENT" && project.engine === "V2" && ( - - - - - -
- -
-
- - {isConnected === undefined - ? "Checking connection…" - : isConnected - ? "Your dev server is connected" - : "Your dev server is not connected"} - -
-
- -
-
- )} +
+ + {environment.type === "DEVELOPMENT" && project.engine === "V2" && ( + + + + + +
+ +
+
+ + {isConnected === undefined + ? "Checking connection…" + : isConnected + ? "Your dev server is connected" + : "Your dev server is not connected"} + +
+
+ +
+
+ )} +
From d7c6af0367ac119c3bd0afb2b5598133a22c33c0 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 29 Jun 2026 19:02:11 +0100 Subject: [PATCH 04/29] feat(webapp): size side menu popover items to match the nav items Bump the org menu and project picker popover items to a 20px leading icon and a 0.90625rem label so they match the side menu nav items. The project folder icons keep their indigo color and the Vercel and Slack icons keep their smaller size. --- .../app/components/navigation/SideMenu.tsx | 69 ++++++++++++++----- 1 file changed, 50 insertions(+), 19 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index e38fa86577..f032b460b0 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -144,6 +144,15 @@ function getSectionCollapsed( return sideMenu?.collapsedSections?.[sectionId] ?? false; } +// Size the side menu popover items (org menu + project picker) to match the side +// menu items: a 20px leading icon and a 0.90625rem label (vs the smaller +// small-menu-item defaults). The icon class overrides the variant icon size; the +// label class lands on the button element and overrides its text-2sm via +// tailwind-merge. The icon constant also carries the default dimmed color; items +// that need a different icon color (e.g. the indigo project folders) set their own. +const SIDE_MENU_POPOVER_ITEM_ICON = "h-5 w-5 text-text-dimmed"; +const SIDE_MENU_POPOVER_ITEM_LABEL = "text-[0.90625rem] font-medium tracking-[-0.01em]"; + type SideMenuUser = Pick< UserWithDashboardPreferences, "email" | "admin" | "dashboardPreferences" @@ -908,14 +917,16 @@ function OrgSelector({ to={organizationSettingsPath(organization)} title="Settings" icon={SlidersIcon} - leadingIconClassName="text-text-dimmed" + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} /> {isManagedCloud && ( )} {isManagedCloud && ( @@ -928,7 +939,8 @@ function OrgSelector({
} icon={CreditCardIcon} - leadingIconClassName="text-text-dimmed" + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} /> )} {isManagedCloud && showSelfServe && ( @@ -936,21 +948,24 @@ function OrgSelector({ to={v3BillingLimitsPath(organization)} title="Billing alerts" icon={BellIcon} - leadingIconClassName="text-text-dimmed" + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} /> )} {featureFlags.hasPrivateConnections && ( )} {isUsingRbacPlugin && ( @@ -958,7 +973,8 @@ function OrgSelector({ to={organizationRolesPath(organization)} title="Roles" icon={RolesIcon} - leadingIconClassName="text-text-dimmed" + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} /> )} {isUsingSsoPlugin && ( @@ -966,7 +982,8 @@ function OrgSelector({ to={organizationSsoPath(organization)} title="SSO" icon={PadlockIcon} - leadingIconClassName="text-text-dimmed" + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} /> )} @@ -977,7 +994,8 @@ function OrgSelector({ to={newOrganizationPath()} title="New organization" icon={PlusIcon} - leadingIconClassName="text-text-dimmed" + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} /> )}
@@ -986,7 +1004,8 @@ function OrgSelector({ to={accountPath()} title="Account" icon={AvatarCircleIcon} - leadingIconClassName="text-text-dimmed" + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} />
@@ -994,7 +1013,8 @@ function OrgSelector({ to={logoutPath()} title="Logout" icon={ArrowRightSquareIcon} - leadingIconClassName="text-text-dimmed" + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} danger />
@@ -1084,11 +1104,18 @@ function ProjectSelector({ } isSelected={isSelected} icon={isSelected ? FolderOpenIcon : FolderClosedIcon} - leadingIconClassName="text-indigo-500" + leadingIconClassName="h-5 w-5 text-indigo-500" + className={SIDE_MENU_POPOVER_ITEM_LABEL} /> ); })} - + @@ -1139,9 +1166,9 @@ function SwitchOrganizations({ } + icon={} leadingIconClassName="text-text-dimmed" + className={SIDE_MENU_POPOVER_ITEM_LABEL} isSelected={org.id === organization.id} /> ))} @@ -1177,7 +1205,8 @@ function SwitchOrganizations({ to={newOrganizationPath()} title="New organization" icon={PlusIcon} - leadingIconClassName="text-text-dimmed" + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} /> @@ -1224,9 +1253,9 @@ function Integrations({ organization }: { organization: MatchedOrganization }) { From 3aa2886958b90fecdfc2206342a7e61c8bb79a58 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 30 Jun 2026 14:18:21 +0100 Subject: [PATCH 05/29] feat(webapp): pin the project and environment selectors above the scroll area Move the Project header, project picker, and environment selector into a fixed region so they stay put while the nav list scrolls, and shift the scroll divider down to the top of the scrollable section. --- .../app/components/navigation/SideMenu.tsx | 117 +++++++++--------- 1 file changed, 59 insertions(+), 58 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index f032b460b0..4ea1178c6d 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -314,13 +314,8 @@ export function SideMenu({ )} > -
-
+
+
-
-
- -
- + +
+ +
+ -
- - {environment.type === "DEVELOPMENT" && project.engine === "V2" && ( - - - - - -
- -
-
- - {isConnected === undefined - ? "Checking connection…" - : isConnected - ? "Your dev server is connected" - : "Your dev server is not connected"} - -
-
- -
-
- )} -
+ {environment.type === "DEVELOPMENT" && project.engine === "V2" && ( + + + + + +
+ +
+
+ + {isConnected === undefined + ? "Checking connection…" + : isConnected + ? "Your dev server is connected" + : "Your dev server is not connected"} + +
+
+ +
+
+ )}
- +
+
+
+
Date: Tue, 30 Jun 2026 18:02:55 +0100 Subject: [PATCH 06/29] feat(webapp): make the side menu env switcher a segmented control The side menu environment switcher is now a segmented control (Dev, Staging, Prev, Prod) instead of a dropdown, with each segment tinted in its environment color when active. The Dev segment shows the local dev server connection as a small pulsing dot with a hover tooltip, replacing the old connection button and modal. Preview opens a popover to switch branches and shows the active branch name. When the side menu is collapsed it falls back to the original dropdown selector, which is also still used on the blank-state panels and the Limits page. --- .../EnvironmentSegmentedControl.tsx | 241 ++++++++++++++++++ .../navigation/EnvironmentSelector.tsx | 240 ++++++++--------- .../app/components/navigation/SideMenu.tsx | 84 +----- 3 files changed, 378 insertions(+), 187 deletions(-) create mode 100644 apps/webapp/app/components/navigation/EnvironmentSegmentedControl.tsx diff --git a/apps/webapp/app/components/navigation/EnvironmentSegmentedControl.tsx b/apps/webapp/app/components/navigation/EnvironmentSegmentedControl.tsx new file mode 100644 index 0000000000..f6d82b5225 --- /dev/null +++ b/apps/webapp/app/components/navigation/EnvironmentSegmentedControl.tsx @@ -0,0 +1,241 @@ +import { Link, useNavigation } from "@remix-run/react"; +import { motion } from "framer-motion"; +import { useEffect, useState } from "react"; +import { DropdownIcon } from "~/assets/icons/DropdownIcon"; +import { useEnvironmentSwitcher } from "~/hooks/useEnvironmentSwitcher"; +import { useFeatures } from "~/hooks/useFeatures"; +import { type MatchedOrganization } from "~/hooks/useOrganizations"; +import { cn } from "~/utils/cn"; +import { v3BillingPath } from "~/utils/pathBuilder"; +import { useDevPresence } from "../DevPresence"; +import { environmentTextClassName } from "../environments/EnvironmentLabel"; +import { + Popover, + PopoverContent, + PopoverSectionHeader, + PopoverTrigger, +} from "../primitives/Popover"; +import { SimpleTooltip } from "../primitives/Tooltip"; +import { BranchesPopoverContent, EnvironmentSelector } from "./EnvironmentSelector"; +import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu"; + +type EnvType = "DEVELOPMENT" | "STAGING" | "PREVIEW" | "PRODUCTION"; + +const SEGMENTS: { type: EnvType; label: string }[] = [ + { type: "DEVELOPMENT", label: "Dev" }, + { type: "STAGING", label: "Staging" }, + { type: "PREVIEW", label: "Prev" }, + { type: "PRODUCTION", label: "Prod" }, +]; + +// Upgrade copy mirrors the dropdown's "Additional environments" prompts. +const UPGRADE_MESSAGE: Partial> = { + STAGING: "Upgrade to unlock a Staging environment for your projects.", + PREVIEW: "Upgrade to unlock Preview environments for your projects.", +}; + +const PILL_LAYOUT_ID = "env-segmented-pill"; + +// The sliding selected pill is tinted with the env's own color. +const ENV_PILL: Record = { + DEVELOPMENT: "border-dev/30 bg-dev/15", + STAGING: "border-staging/30 bg-staging/15", + PREVIEW: "border-preview/30 bg-preview/15", + PRODUCTION: "border-prod/30 bg-prod/15", +}; + +const SEGMENT_CLASS = + "group relative flex h-full grow items-center justify-center gap-1 rounded px-1.5 text-xs font-medium outline-none focus-custom"; + +/** + * Side-menu-only environment switcher rendered as a segmented control (Dev / + * Staging / Prev / Prod) instead of the dropdown `EnvironmentSelector`. The + * dropdown is still used on blank-state panels and the Limits page. + */ +export function EnvironmentSegmentedControl({ + organization, + project, + environment, + isCollapsed = false, +}: { + organization: MatchedOrganization; + project: SideMenuProject; + environment: SideMenuEnvironment; + isCollapsed?: boolean; +}) { + const { urlForEnvironment } = useEnvironmentSwitcher(); + const { isManagedCloud } = useFeatures(); + const { isConnected } = useDevPresence(); + const navigation = useNavigation(); + const [isPreviewOpen, setIsPreviewOpen] = useState(false); + + useEffect(() => { + setIsPreviewOpen(false); + }, [navigation.location?.pathname]); + + const rootEnvFor = (type: EnvType) => + project.environments.find((e) => e.type === type && e.parentEnvironmentId === null); + + // Which segment is active. A branch (e.g. a preview branch) resolves to its + // parent env's type, so the parent segment shows as active. + const activeType: EnvType = (() => { + if (environment.parentEnvironmentId) { + const parent = project.environments.find((e) => e.id === environment.parentEnvironmentId); + if (parent) return parent.type as EnvType; + } + return environment.type as EnvType; + })(); + + // While the Preview popover is open, show Preview as the selected segment even + // if we haven't navigated into a preview branch yet. + const selectedType: EnvType = isPreviewOpen ? "PREVIEW" : activeType; + + // The dev connection is only tracked while you're in the dev environment (the + // presence provider is enabled there), matching the old connection button. + const showDevDot = environment.type === "DEVELOPMENT" && project.engine === "V2"; + + // When collapsed there's no room for the segmented control, so fall back to the + // original dropdown selector (env icon + menu). + if (isCollapsed) { + return ( + + ); + } + + return ( +
+ {SEGMENTS.map((segment) => { + const env = rootEnvFor(segment.type); + const isSelected = selectedType === segment.type; + const colorClass = isSelected + ? environmentTextClassName({ type: segment.type }) + : "text-text-dimmed transition group-hover:text-text-bright"; + + const pill = isSelected ? ( + + ) : null; + + // Preview: opens the branch switcher popover on click (active or not). + if (segment.type === "PREVIEW" && env) { + const branchEnvironments = project.environments.filter( + (e) => e.parentEnvironmentId === env.id + ); + // When the active environment is a preview branch, show the branch name + // truncated to 4 characters (plus an ellipsis) instead of "Prev". + const branchName = + environment.parentEnvironmentId === env.id ? environment.branchName : null; + const previewLabel = + branchName && branchName.length > 4 + ? `${branchName.slice(0, 4)}…` + : (branchName ?? segment.label); + return ( + + + {pill} + {previewLabel} + + + + + + + + ); + } + + // Missing env (Staging/Preview not provisioned): upgrade link on managed + // cloud, otherwise a disabled segment. + if (!env) { + const upgradeMessage = UPGRADE_MESSAGE[segment.type]; + if (isManagedCloud && upgradeMessage) { + return ( + + + {segment.label} + + + ); + } + return ( +
+ {segment.label} +
+ ); + } + + // Dev / Staging / Prod: navigate to the environment. + return ( + + {pill} + {segment.label} + {segment.type === "DEVELOPMENT" && showDevDot ? ( + + ) : null} + + ); + })} +
+ ); +} + +/** + * Small status dot (top-right of the Dev segment) replacing the old connection + * button: a pulsing green dot when connected, a static grey dot otherwise. Hover + * (after a short delay) reveals the connection status as a tooltip. + */ +function DevConnectionDot({ isConnected }: { isConnected: boolean | undefined }) { + const content = + isConnected === undefined + ? "Checking connection…" + : isConnected + ? "Your dev server is connected" + : "Your dev server is not connected"; + + return ( + + {isConnected ? ( + + ) : null} + + + } + /> + ); +} diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index 6701c07213..e6e7425395 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -41,16 +41,12 @@ export function EnvironmentSelector({ environment, className, isCollapsed = false, - showConnector = false, }: { organization: MatchedOrganization; project: SideMenuProject; environment: SideMenuEnvironment; className?: string; isCollapsed?: boolean; - /** Show an end tree-connector to the left of the icon so the selector reads - * as connected to the Project menu above it. Only used in the side menu. */ - showConnector?: boolean; }) { const { isManagedCloud } = useFeatures(); const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -75,21 +71,6 @@ export function EnvironmentSelector({ className )} > - {showConnector && - !isCollapsed && ( - // End tree-connector sized to the full button height (viewBox matches the - // 20x32 box) so the vertical line reaches the button's top edge and meets the - // Project button above, with the corner aligned to the environment icon's center. - - - - - )} (null); @@ -251,23 +228,6 @@ function Branches({ }, 150); }; - const activeBranches = branchEnvironments.filter((env) => env.archivedAt === null); - const state = - branchEnvironments.length === 0 - ? "no-branches" - : activeBranches.length === 0 - ? "no-active-branches" - : "has-branches"; - - // Only surface the active environment's archived-branch item in the submenu it - // actually belongs to. Both Development and Preview render this component, so - // without the parent check an archived dev branch would leak into the Preview - // submenu (and vice-versa). - const currentBranchIsArchived = - environment.archivedAt !== null && environment.parentEnvironmentId === parentEnvironment.id; - - const envTextClassName = environmentTextClassName(parentEnvironment); - return ( setMenuOpen(open)} open={isMenuOpen}>
@@ -293,88 +253,134 @@ function Branches({ onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} > -
- {currentBranchIsArchived && ( - + + +
+ + ); +} + +/** + * The inner content of the branches popover (branch list, empty states, and the + * "Manage branches" footer). Shared by the dropdown's hover submenu (`Branches`) + * and the side-menu segmented control's Preview popover. + */ +export function BranchesPopoverContent({ + parentEnvironment, + branchEnvironments, + currentEnvironment, +}: { + parentEnvironment: SideMenuEnvironment; + branchEnvironments: SideMenuEnvironment[]; + currentEnvironment: SideMenuEnvironment; +}) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const { urlForEnvironment } = useEnvironmentSwitcher(); + + const activeBranches = branchEnvironments.filter((env) => env.archivedAt === null); + const state = + branchEnvironments.length === 0 + ? "no-branches" + : activeBranches.length === 0 + ? "no-active-branches" + : "has-branches"; + + // Only surface the active environment's archived-branch item in the submenu it + // actually belongs to. Both Development and Preview render this component, so + // without the parent check an archived dev branch would leak into the Preview + // submenu (and vice-versa). + const currentBranchIsArchived = + environment.archivedAt !== null && environment.parentEnvironmentId === parentEnvironment.id; + + const envTextClassName = environmentTextClassName(parentEnvironment); + + return ( + <> +
+ {currentBranchIsArchived && ( + + + {environment.branchName} + + Archived + + } + icon={ + + } + isSelected={environment.id === currentEnvironment.id} + /> + )} + {state === "has-branches" ? ( + <> + {branchEnvironments + .filter((env) => env.archivedAt === null) + .map((env) => ( + - {environment.branchName} + {env.branchName ?? DEFAULT_DEV_BRANCH} - Archived - - } - icon={ - - } - isSelected={environment.id === currentEnvironment.id} - /> - )} - {state === "has-branches" ? ( - <> - {branchEnvironments - .filter((env) => env.archivedAt === null) - .map((env) => ( - - {env.branchName ?? DEFAULT_DEV_BRANCH} - - } - icon={ - - } - isSelected={env.id === currentEnvironment.id} + } + icon={ + - ))} - - ) : state === "no-branches" ? ( -
-
- - Create your first branch -
- - Branches are a way to test new features in isolation before merging them into the - main environment. - - - Branches are only available when using or above. Read our{" "} - v4 upgrade guide to learn - more. - -
- ) : ( -
- All branches are archived. -
- )} + } + isSelected={env.id === currentEnvironment.id} + /> + ))} + + ) : state === "no-branches" ? ( +
+
+ + Create your first branch +
+ + Branches are a way to test new features in isolation before merging them into the main + environment. + + + Branches are only available when using or above. Read our{" "} + v4 upgrade guide to learn more. +
-
- {parentEnvironment.type === "DEVELOPMENT" ? ( - } - leadingIconClassName="text-text-dimmed" - /> - ) : ( - } - leadingIconClassName="text-text-dimmed" - /> - )} + ) : ( +
+ All branches are archived.
- + )}
- +
+ {parentEnvironment.type === "DEVELOPMENT" ? ( + } + leadingIconClassName="text-text-dimmed" + /> + ) : ( + } + leadingIconClassName="text-text-dimmed" + /> + )} +
+ ); } diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 4ea1178c6d..40ab558f76 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -110,10 +110,8 @@ import { import { AlphaBadge, NewBadge } from "../FeatureBadges"; import { AskAI } from "../AskAI"; import { FreePlanUsage } from "../billing/FreePlanUsage"; -import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence"; import { ImpersonationBanner } from "../ImpersonationBanner"; -import { Button, ButtonContent, LinkButton } from "../primitives/Buttons"; -import { Dialog, DialogTrigger } from "../primitives/Dialog"; +import { ButtonContent, LinkButton } from "../primitives/Buttons"; import { Paragraph } from "../primitives/Paragraph"; import { Badge } from "../primitives/Badge"; import { Popover, PopoverContent, PopoverMenuItem, PopoverTrigger } from "../primitives/Popover"; @@ -128,7 +126,7 @@ import { import { ShortcutsAutoOpen } from "../Shortcuts"; import { CreateDashboardButton } from "./DashboardDialogs"; import { DashboardList } from "./DashboardList"; -import { EnvironmentSelector } from "./EnvironmentSelector"; +import { EnvironmentSegmentedControl } from "./EnvironmentSegmentedControl"; import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; @@ -182,8 +180,6 @@ export function SideMenu({ organization, organizations, }: SideMenuProps) { - const borderRef = useRef(null); - const [showHeaderDivider, setShowHeaderDivider] = useState(false); const [isCollapsed, setIsCollapsed] = useState( user.dashboardPreferences.sideMenu?.isCollapsed ?? false ); @@ -195,7 +191,6 @@ export function SideMenu({ }>({}); const debounceTimeoutRef = useRef(null); const currentPlan = useCurrentPlan(); - const { isConnected } = useDevPresence(); const isFreeUser = currentPlan?.v3Subscription?.isPaying === false; const isAdmin = useHasAdminAccess(); const { isManagedCloud } = useFeatures(); @@ -292,20 +287,6 @@ export function SideMenu({ action: handleToggleCollapsed, }); - useEffect(() => { - const handleScroll = () => { - if (borderRef.current) { - const shouldShowHeaderDivider = borderRef.current.scrollTop > 1; - if (showHeaderDivider !== shouldShowHeaderDivider) { - setShowHeaderDivider(shouldShowHeaderDivider); - } - } - }; - - borderRef.current?.addEventListener("scroll", handleScroll); - return () => borderRef.current?.removeEventListener("scroll", handleScroll); - }, [showHeaderDivider]); - return (
) : null}
-
+
-
+
-
- - {environment.type === "DEVELOPMENT" && project.engine === "V2" && ( - - - - - -
- -
-
- - {isConnected === undefined - ? "Checking connection…" - : isConnected - ? "Your dev server is connected" - : "Your dev server is not connected"} - -
-
- -
-
- )} -
+
@@ -1049,7 +991,9 @@ function ProjectSelector({ From f2a3cbe234e17de85bc7c09cf1df404f7638215e Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 2 Jul 2026 10:03:31 +0100 Subject: [PATCH 07/29] feat(webapp): refine side menu selectors and spacing - Revert the environment segmented control back to the dropdown selector, restoring the dev connection button and its modal - Hide the org, project, and environment selector chevrons until hover - Reorganize the project popover so New project and Project settings sit above a divider, before the project list - Remove the Environments header from the environment popover - Fix the dev connection button being clipped and tune the scroll section padding --- .../EnvironmentSegmentedControl.tsx | 241 ------------------ .../navigation/EnvironmentSelector.tsx | 3 +- .../app/components/navigation/SideMenu.tsx | 89 +++++-- 3 files changed, 69 insertions(+), 264 deletions(-) delete mode 100644 apps/webapp/app/components/navigation/EnvironmentSegmentedControl.tsx diff --git a/apps/webapp/app/components/navigation/EnvironmentSegmentedControl.tsx b/apps/webapp/app/components/navigation/EnvironmentSegmentedControl.tsx deleted file mode 100644 index f6d82b5225..0000000000 --- a/apps/webapp/app/components/navigation/EnvironmentSegmentedControl.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import { Link, useNavigation } from "@remix-run/react"; -import { motion } from "framer-motion"; -import { useEffect, useState } from "react"; -import { DropdownIcon } from "~/assets/icons/DropdownIcon"; -import { useEnvironmentSwitcher } from "~/hooks/useEnvironmentSwitcher"; -import { useFeatures } from "~/hooks/useFeatures"; -import { type MatchedOrganization } from "~/hooks/useOrganizations"; -import { cn } from "~/utils/cn"; -import { v3BillingPath } from "~/utils/pathBuilder"; -import { useDevPresence } from "../DevPresence"; -import { environmentTextClassName } from "../environments/EnvironmentLabel"; -import { - Popover, - PopoverContent, - PopoverSectionHeader, - PopoverTrigger, -} from "../primitives/Popover"; -import { SimpleTooltip } from "../primitives/Tooltip"; -import { BranchesPopoverContent, EnvironmentSelector } from "./EnvironmentSelector"; -import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu"; - -type EnvType = "DEVELOPMENT" | "STAGING" | "PREVIEW" | "PRODUCTION"; - -const SEGMENTS: { type: EnvType; label: string }[] = [ - { type: "DEVELOPMENT", label: "Dev" }, - { type: "STAGING", label: "Staging" }, - { type: "PREVIEW", label: "Prev" }, - { type: "PRODUCTION", label: "Prod" }, -]; - -// Upgrade copy mirrors the dropdown's "Additional environments" prompts. -const UPGRADE_MESSAGE: Partial> = { - STAGING: "Upgrade to unlock a Staging environment for your projects.", - PREVIEW: "Upgrade to unlock Preview environments for your projects.", -}; - -const PILL_LAYOUT_ID = "env-segmented-pill"; - -// The sliding selected pill is tinted with the env's own color. -const ENV_PILL: Record = { - DEVELOPMENT: "border-dev/30 bg-dev/15", - STAGING: "border-staging/30 bg-staging/15", - PREVIEW: "border-preview/30 bg-preview/15", - PRODUCTION: "border-prod/30 bg-prod/15", -}; - -const SEGMENT_CLASS = - "group relative flex h-full grow items-center justify-center gap-1 rounded px-1.5 text-xs font-medium outline-none focus-custom"; - -/** - * Side-menu-only environment switcher rendered as a segmented control (Dev / - * Staging / Prev / Prod) instead of the dropdown `EnvironmentSelector`. The - * dropdown is still used on blank-state panels and the Limits page. - */ -export function EnvironmentSegmentedControl({ - organization, - project, - environment, - isCollapsed = false, -}: { - organization: MatchedOrganization; - project: SideMenuProject; - environment: SideMenuEnvironment; - isCollapsed?: boolean; -}) { - const { urlForEnvironment } = useEnvironmentSwitcher(); - const { isManagedCloud } = useFeatures(); - const { isConnected } = useDevPresence(); - const navigation = useNavigation(); - const [isPreviewOpen, setIsPreviewOpen] = useState(false); - - useEffect(() => { - setIsPreviewOpen(false); - }, [navigation.location?.pathname]); - - const rootEnvFor = (type: EnvType) => - project.environments.find((e) => e.type === type && e.parentEnvironmentId === null); - - // Which segment is active. A branch (e.g. a preview branch) resolves to its - // parent env's type, so the parent segment shows as active. - const activeType: EnvType = (() => { - if (environment.parentEnvironmentId) { - const parent = project.environments.find((e) => e.id === environment.parentEnvironmentId); - if (parent) return parent.type as EnvType; - } - return environment.type as EnvType; - })(); - - // While the Preview popover is open, show Preview as the selected segment even - // if we haven't navigated into a preview branch yet. - const selectedType: EnvType = isPreviewOpen ? "PREVIEW" : activeType; - - // The dev connection is only tracked while you're in the dev environment (the - // presence provider is enabled there), matching the old connection button. - const showDevDot = environment.type === "DEVELOPMENT" && project.engine === "V2"; - - // When collapsed there's no room for the segmented control, so fall back to the - // original dropdown selector (env icon + menu). - if (isCollapsed) { - return ( - - ); - } - - return ( -
- {SEGMENTS.map((segment) => { - const env = rootEnvFor(segment.type); - const isSelected = selectedType === segment.type; - const colorClass = isSelected - ? environmentTextClassName({ type: segment.type }) - : "text-text-dimmed transition group-hover:text-text-bright"; - - const pill = isSelected ? ( - - ) : null; - - // Preview: opens the branch switcher popover on click (active or not). - if (segment.type === "PREVIEW" && env) { - const branchEnvironments = project.environments.filter( - (e) => e.parentEnvironmentId === env.id - ); - // When the active environment is a preview branch, show the branch name - // truncated to 4 characters (plus an ellipsis) instead of "Prev". - const branchName = - environment.parentEnvironmentId === env.id ? environment.branchName : null; - const previewLabel = - branchName && branchName.length > 4 - ? `${branchName.slice(0, 4)}…` - : (branchName ?? segment.label); - return ( - - - {pill} - {previewLabel} - - - - - - - - ); - } - - // Missing env (Staging/Preview not provisioned): upgrade link on managed - // cloud, otherwise a disabled segment. - if (!env) { - const upgradeMessage = UPGRADE_MESSAGE[segment.type]; - if (isManagedCloud && upgradeMessage) { - return ( - - - {segment.label} - - - ); - } - return ( -
- {segment.label} -
- ); - } - - // Dev / Staging / Prod: navigate to the environment. - return ( - - {pill} - {segment.label} - {segment.type === "DEVELOPMENT" && showDevDot ? ( - - ) : null} - - ); - })} -
- ); -} - -/** - * Small status dot (top-right of the Dev segment) replacing the old connection - * button: a pulsing green dot when connected, a static grey dot otherwise. Hover - * (after a short delay) reveals the connection status as a tooltip. - */ -function DevConnectionDot({ isConnected }: { isConnected: boolean | undefined }) { - const content = - isConnected === undefined - ? "Checking connection…" - : isConnected - ? "Your dev server is connected" - : "Your dev server is not connected"; - - return ( - - {isConnected ? ( - - ) : null} - - - } - /> - ); -} diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index e6e7425395..e26facf1c4 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -89,7 +89,7 @@ export function EnvironmentSelector({ @@ -111,7 +111,6 @@ export function EnvironmentSelector({ align="start" style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }} > -
{project.environments .filter((env) => env.parentEnvironmentId === null) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 40ab558f76..469ba7abb5 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -110,8 +110,10 @@ import { import { AlphaBadge, NewBadge } from "../FeatureBadges"; import { AskAI } from "../AskAI"; import { FreePlanUsage } from "../billing/FreePlanUsage"; +import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence"; import { ImpersonationBanner } from "../ImpersonationBanner"; -import { ButtonContent, LinkButton } from "../primitives/Buttons"; +import { Button, ButtonContent, LinkButton } from "../primitives/Buttons"; +import { Dialog, DialogTrigger } from "../primitives/Dialog"; import { Paragraph } from "../primitives/Paragraph"; import { Badge } from "../primitives/Badge"; import { Popover, PopoverContent, PopoverMenuItem, PopoverTrigger } from "../primitives/Popover"; @@ -126,7 +128,7 @@ import { import { ShortcutsAutoOpen } from "../Shortcuts"; import { CreateDashboardButton } from "./DashboardDialogs"; import { DashboardList } from "./DashboardList"; -import { EnvironmentSegmentedControl } from "./EnvironmentSegmentedControl"; +import { EnvironmentSelector } from "./EnvironmentSelector"; import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; @@ -191,6 +193,7 @@ export function SideMenu({ }>({}); const debounceTimeoutRef = useRef(null); const currentPlan = useCurrentPlan(); + const { isConnected } = useDevPresence(); const isFreeUser = currentPlan?.v3Subscription?.isPaying === false; const isAdmin = useHasAdminAccess(); const { isManagedCloud } = useFeatures(); @@ -329,34 +332,67 @@ export function SideMenu({ ) : null}
-
+
- +
+ + {environment.type === "DEVELOPMENT" && project.engine === "V2" && ( + + + + + +
+ +
+
+ + {isConnected === undefined + ? "Checking connection…" + : isConnected + ? "Your dev server is connected" + : "Your dev server is not connected"} + +
+
+ +
+
+ )} +
-
+
@@ -969,11 +1005,13 @@ function OrgSelector({ function ProjectSelector({ project, organization, + environment, isCollapsed = false, className, }: { project: SideMenuProject; organization: MatchedOrganization; + environment: SideMenuEnvironment; isCollapsed?: boolean; className?: string; }) { @@ -1013,7 +1051,7 @@ function ProjectSelector({ @@ -1036,6 +1074,22 @@ function ProjectSelector({ style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }} >
+ + +
+
{organization.projects.map((p) => { const isSelected = p.id === project.id; return ( @@ -1054,13 +1108,6 @@ function ProjectSelector({ /> ); })} -
From 8e37da9b5cf7d430278590d3d89b9778da451666 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 2 Jul 2026 10:56:03 +0100 Subject: [PATCH 08/29] feat(webapp): polish the project selector, help menu, and collapsed side menu - Brighten the project selector trigger label and icon - Keep a constant 1px border on the project selector (transparent when collapsed) so it no longer nudges 1px on collapse - Animate the scroll section left padding in sync with the side menu collapse, matching its duration and easing - Remove the H shortcut key from the Help & Feedback button and surface it in a 500ms hover tooltip instead, keeping the shortcut working - Match the Help & Feedback label to the other side menu items (size and dimmed color) --- .../navigation/HelpAndFeedbackPopover.tsx | 12 ++---------- .../app/components/navigation/SideMenu.tsx | 17 +++++++++++------ 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx index 2eaa85dd81..bbd5c3ea92 100644 --- a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx +++ b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx @@ -70,21 +70,13 @@ export function HelpAndFeedback({ Help & Feedback
- } content={ @@ -95,7 +87,7 @@ export function HelpAndFeedback({ } side="right" sideOffset={8} - hidden={!isCollapsed} + delayDuration={isCollapsed ? 0 : 500} buttonClassName="!h-8 w-full" asChild disableHoverableContent diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 469ba7abb5..44dfa840d7 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -392,7 +392,12 @@ export function SideMenu({ : "scrollbar-gutter-stable scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600" )} > -
+
- + - + {project.name ?? "Select a project"} From 2fe465cc83493acc5bb3ddef3dedbd506445479d Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 2 Jul 2026 14:07:18 +0100 Subject: [PATCH 09/29] feat(webapp): align the project section padding with the nav items - Apply the scrollable section left padding to the top Project section (Project header, project selector, environment selector), animated in sync with the collapse - Keep the menus reducing width instead of shifting right by holding their right edge - Match the section right and bottom padding to the left padding for symmetry, since there is no scrollbar in this section - Leave the organization menu unchanged --- apps/webapp/app/components/navigation/SideMenu.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 44dfa840d7..b9e4339dd2 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -332,7 +332,12 @@ export function SideMenu({ ) : null}
-
+
From f593730a3b8792f9a3f57e59d4d8174860652b18 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 2 Jul 2026 18:03:27 +0100 Subject: [PATCH 10/29] feat(webapp): add a collapse toggle button to the side menu footer - Replace the Ask AI button in the side menu footer with a collapse toggle that mirrors the middle handle (toggles the menu, Collapse/Expand tooltip with the cmd+b shortcut) - Add an animated LeftSideMenuIcon (scaleX transform so it animates on the compositor and never snaps) that animates on button hover, dimmed by default and bright on hover - Swap in a static LeftSideMenuCollapsedIcon when the menu is collapsed, and match the tooltip open delay to the Help and Feedback button - Add LeftSideMenuIcon to the storybook icons route Removes the Ask AI button and its cmd+i shortcut from the footer. --- .../icons/LeftSideMenuCollapsedIcon.tsx | 22 +++++++ .../app/assets/icons/LeftSideMenuIcon.tsx | 49 ++++++++++++++ .../app/components/navigation/SideMenu.tsx | 66 ++++++++++++++++++- .../app/routes/storybook.icons/route.tsx | 2 + 4 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 apps/webapp/app/assets/icons/LeftSideMenuCollapsedIcon.tsx create mode 100644 apps/webapp/app/assets/icons/LeftSideMenuIcon.tsx diff --git a/apps/webapp/app/assets/icons/LeftSideMenuCollapsedIcon.tsx b/apps/webapp/app/assets/icons/LeftSideMenuCollapsedIcon.tsx new file mode 100644 index 0000000000..aa229af92a --- /dev/null +++ b/apps/webapp/app/assets/icons/LeftSideMenuCollapsedIcon.tsx @@ -0,0 +1,22 @@ +export function LeftSideMenuCollapsedIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/LeftSideMenuIcon.tsx b/apps/webapp/app/assets/icons/LeftSideMenuIcon.tsx new file mode 100644 index 0000000000..7db45082eb --- /dev/null +++ b/apps/webapp/app/assets/icons/LeftSideMenuIcon.tsx @@ -0,0 +1,49 @@ +import { motion } from "framer-motion"; +import { useState } from "react"; + +export function LeftSideMenuIcon({ + className, + hovered: controlledHovered, +}: { + className?: string; + /** + * When provided, the shape animation is driven by this prop (e.g. from a + * parent button's hover). When omitted, the icon animates on its own hover. + */ + hovered?: boolean; +}) { + const [internalHovered, setInternalHovered] = useState(false); + const isControlled = controlledHovered !== undefined; + const hovered = isControlled ? controlledHovered : internalHovered; + + return ( + setInternalHovered(true)} + onMouseLeave={isControlled ? undefined : () => setInternalHovered(false)} + > + + {/* Animate a transform (scaleX) rather than the SVG `width` attribute: + framer snaps the first animation of an SVG geometry attribute after it + has been idle, whereas transforms animate reliably. Anchoring the origin + to the left edge collapses the panel from right to left. */} + + + ); +} diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index d4f124fdf9..84c84a2b0b 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -30,6 +30,8 @@ import { HomeIcon } from "~/assets/icons/HomeIcon"; import { IDIcon } from "~/assets/icons/IDIcon"; import { IntegrationsIcon } from "~/assets/icons/IntegrationsIcon"; import { KeyIcon } from "~/assets/icons/KeyIcon"; +import { LeftSideMenuCollapsedIcon } from "~/assets/icons/LeftSideMenuCollapsedIcon"; +import { LeftSideMenuIcon } from "~/assets/icons/LeftSideMenuIcon"; import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; import { LogsIcon } from "~/assets/icons/LogsIcon"; import { PlusIcon } from "~/assets/icons/PlusIcon"; @@ -105,7 +107,6 @@ import { v3UsagePath, v3WaitpointTokensPath, } from "~/utils/pathBuilder"; -import { AskAI } from "../AskAI"; import { FreePlanUsage } from "../billing/FreePlanUsage"; import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence"; import { AlphaBadge, NewBadge } from "../FeatureBadges"; @@ -716,6 +717,7 @@ export function SideMenu({ isCollapsed={isCollapsed} organizationId={organization.id} projectId={project.id} + onToggleCollapsed={handleToggleCollapsed} /> {isFreeUser && ( @@ -1346,10 +1348,12 @@ function HelpAndAI({ isCollapsed, organizationId, projectId, + onToggleCollapsed, }: { isCollapsed: boolean; organizationId: string; projectId: string; + onToggleCollapsed: () => void; }) { return ( @@ -1365,12 +1369,70 @@ function HelpAndAI({ organizationId={organizationId} projectId={projectId} /> - +
); } +function CollapseMenuButton({ + isCollapsed, + onToggle, +}: { + isCollapsed: boolean; + onToggle: () => void; +}) { + const [isHovering, setIsHovering] = useState(false); + + return ( +
+ + + + setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + > + + + + + {isCollapsed ? "Expand" : "Collapse"} + + + + + + + +
+ ); +} + function AnimatedChevron({ isHovering, isCollapsed, diff --git a/apps/webapp/app/routes/storybook.icons/route.tsx b/apps/webapp/app/routes/storybook.icons/route.tsx index dee5aa3e25..a465e05fb5 100644 --- a/apps/webapp/app/routes/storybook.icons/route.tsx +++ b/apps/webapp/app/routes/storybook.icons/route.tsx @@ -81,6 +81,7 @@ import { KeyboardUpIcon } from "~/assets/icons/KeyboardUpIcon"; import { KeyboardWindowsIcon } from "~/assets/icons/KeyboardWindowsIcon"; import { KeyIcon } from "~/assets/icons/KeyIcon"; import { KeyValueIcon } from "~/assets/icons/KeyValueIcon"; +import { LeftSideMenuIcon } from "~/assets/icons/LeftSideMenuIcon"; import { ListBulletIcon } from "~/assets/icons/ListBulletIcon"; import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; import { LogsIcon } from "~/assets/icons/LogsIcon"; @@ -217,6 +218,7 @@ const icons: IconEntry[] = [ { name: "KeyboardWindowsIcon", render: simple(KeyboardWindowsIcon) }, { name: "KeyIcon", render: simple(KeyIcon) }, { name: "KeyValueIcon", render: simple(KeyValueIcon) }, + { name: "LeftSideMenuIcon", render: simple(LeftSideMenuIcon) }, { name: "ListBulletIcon", render: simple(ListBulletIcon) }, { name: "ListCheckedIcon", render: simple(ListCheckedIcon) }, { name: "LlamaIcon", render: simple(LlamaIcon) }, From d5c39f59970f30dbf2f64d22b300c0db6009bbc7 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 2 Jul 2026 18:18:17 +0100 Subject: [PATCH 11/29] feat(webapp): remove the middle collapse handle in favor of the footer button The side menu footer collapse button now handles toggling, so the middle-right handle (and its animated chevron) is redundant. The cmd+b shortcut is preserved. --- .../app/components/navigation/SideMenu.tsx | 127 ------------------ 1 file changed, 127 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 84c84a2b0b..1938a6d169 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -296,7 +296,6 @@ export function SideMenu({ isCollapsed ? "w-[2.75rem]" : "w-56" )} > -
@@ -1432,129 +1431,3 @@ function CollapseMenuButton({
); } - -function AnimatedChevron({ - isHovering, - isCollapsed, -}: { - isHovering: boolean; - isCollapsed: boolean; -}) { - // When hovering and expanded: left chevron (pointing left to collapse) - // When hovering and collapsed: right chevron (pointing right to expand) - // When not hovering: straight vertical line - - const getRotation = () => { - if (!isHovering) return { top: 0, bottom: 0 }; - if (isCollapsed) { - // Right chevron - return { top: -17, bottom: 17 }; - } else { - // Left chevron - return { top: 17, bottom: -17 }; - } - }; - - const { top, bottom } = getRotation(); - - // Calculate horizontal offset to keep chevron centered when rotated - // Left chevron: translate left (-1.5px) - // Right chevron: translate right (+1.5px) - const getTranslateX = () => { - if (!isHovering) return 0; - return isCollapsed ? 1.5 : -1.5; - }; - - return ( - - {/* Top segment */} - - {/* Bottom segment */} - - - ); -} - -function CollapseToggle({ isCollapsed, onToggle }: { isCollapsed: boolean; onToggle: () => void }) { - const [isHovering, setIsHovering] = useState(false); - - return ( -
- {/* Vertical line to mask the side menu border */} -
- - - - - - - {isCollapsed ? "Expand" : "Collapse"} - - - - - - - -
- ); -} From 24557d8ff77d77f8ae69f64138f190ca620518a2 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 2 Jul 2026 18:27:07 +0100 Subject: [PATCH 12/29] fix(webapp): keep the project section bottom padding constant when collapsing The top section's bottom padding was pb-1 when collapsed and pb-2.5 when open, causing a vertical layout shift below the environment menu on toggle. Make it a constant pb-2.5 so only the horizontal padding animates. --- apps/webapp/app/components/navigation/SideMenu.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 1938a6d169..0cc5700b64 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -332,8 +332,8 @@ export function SideMenu({
From 0b0f66d2d65717b83676b2425eb11eb1c91c6fd7 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 3 Jul 2026 14:29:39 +0100 Subject: [PATCH 13/29] feat(webapp): add an account menu to the side menu header - Add an Account menu (user avatar button) to the top of the side menu, between the organization menu and where the impersonation button used to be - The menu links to Profile, Personal Access Tokens, and Security, plus Logout - For admins it adds an Admin dashboard / Stop impersonating item with the cmd+esc shortcut shown inline, replacing the old permanent admin/impersonation button - Move Logout out of the organization menu into the account menu, and add a divider above Switch organization --- .../app/components/navigation/SideMenu.tsx | 143 ++++++++++++++---- 1 file changed, 115 insertions(+), 28 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 0cc5700b64..b2dce71bad 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -4,7 +4,7 @@ import { ExclamationTriangleIcon, } from "@heroicons/react/24/outline"; import { LinkIcon } from "@heroicons/react/24/solid"; -import { useFetcher, useNavigation } from "@remix-run/react"; +import { useFetcher, useNavigate, useNavigation, useSubmit } from "@remix-run/react"; import { LayoutGroup, motion } from "framer-motion"; import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; import { AIChatIcon } from "~/assets/icons/AIChatIcon"; @@ -37,17 +37,20 @@ import { LogsIcon } from "~/assets/icons/LogsIcon"; import { PlusIcon } from "~/assets/icons/PlusIcon"; import { QueuesIcon } from "~/assets/icons/QueuesIcon"; import { RunsIcon } from "~/assets/icons/RunsIcon"; +import { ShieldIcon } from "~/assets/icons/ShieldIcon"; import { SlidersIcon } from "~/assets/icons/SlidersIcon"; import { TasksIcon } from "~/assets/icons/TasksIcon"; import { UsageIcon } from "~/assets/icons/UsageIcon"; import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; import { CreditCardIcon } from "~/assets/icons/CreditCardIcon"; +import { UserCrossIcon } from "~/assets/icons/UserCrossIcon"; import { UserGroupIcon } from "~/assets/icons/UserGroupIcon"; import { RolesIcon } from "~/assets/icons/RolesIcon"; import { PadlockIcon } from "~/assets/icons/PadlockIcon"; import { SlackIcon } from "~/assets/icons/SlackIcon"; import { VercelLogo } from "~/components/integrations/VercelLogo"; import { Avatar } from "~/components/primitives/Avatar"; +import { UserProfilePhoto } from "~/components/UserProfilePhoto"; import { type MatchedEnvironment } from "~/hooks/useEnvironment"; import { useFeatureFlags } from "~/hooks/useFeatureFlags"; import { useFeatures } from "~/hooks/useFeatures"; @@ -67,6 +70,8 @@ import { IncidentStatusPanel, useIncidentStatus } from "~/routes/resources.incid import { cn } from "~/utils/cn"; import { accountPath, + accountSecurityPath, + personalAccessTokensPath, adminPath, branchesPath, concurrencyPath, @@ -110,7 +115,6 @@ import { import { FreePlanUsage } from "../billing/FreePlanUsage"; import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence"; import { AlphaBadge, NewBadge } from "../FeatureBadges"; -import { ImpersonationBanner } from "../ImpersonationBanner"; import { Button, ButtonContent, LinkButton } from "../primitives/Buttons"; import { Dialog, DialogTrigger } from "../primitives/Dialog"; import { Paragraph } from "../primitives/Paragraph"; @@ -305,30 +309,9 @@ export function SideMenu({ isCollapsed={isCollapsed} />
- {isAdmin && !user.isImpersonating ? ( - - - - - - - - Admin dashboard - - - - - ) : isAdmin && user.isImpersonating ? ( - - - - ) : null} + + +
)} +
+
{organizations.length > 1 ? ( ) : ( @@ -987,14 +972,116 @@ function OrgSelector({ /> )}
-
+ + + ); +} + +function AccountMenu({ isAdmin, isImpersonating }: { isAdmin: boolean; isImpersonating: boolean }) { + const [isOpen, setIsOpen] = useState(false); + const navigation = useNavigation(); + const navigate = useNavigate(); + const submit = useSubmit(); + + useEffect(() => { + setIsOpen(false); + }, [navigation.location?.pathname]); + + const stopImpersonating = () => + submit(null, { action: "/resources/impersonation", method: "delete" }); + + useShortcutKeys({ + shortcut: isAdmin + ? { modifiers: ["mod"], key: "esc", enabledOnInputElements: true } + : undefined, + action: () => { + if (isImpersonating) { + stopImpersonating(); + } else { + navigate(adminPath()); + } + }, + }); + + return ( + setIsOpen(open)} open={isOpen}> + + + + } + content="Account" + side="bottom" + sideOffset={8} + disableHoverableContent + /> + + {isAdmin && ( +
+ {isImpersonating ? ( + + Stop impersonating + + + + +
+ } + icon={UserCrossIcon} + onClick={stopImpersonating} + leadingIconClassName={cn(SIDE_MENU_POPOVER_ITEM_ICON, "text-amber-400")} + className={SIDE_MENU_POPOVER_ITEM_LABEL} + /> + ) : ( + + Admin dashboard + + + + +
+ } + icon={HomeIcon} + leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} + className={SIDE_MENU_POPOVER_ITEM_LABEL} + /> + )} +
+ )} +
+ +
Date: Fri, 3 Jul 2026 14:30:32 +0100 Subject: [PATCH 14/29] feat(webapp): use a custom chain link icon for private connections Add a ChainLinkIcon and use it for the Private connections item in both the organization settings side menu and the organization dropdown, replacing the heroicons link icon. --- .../webapp/app/assets/icons/ChainLinkIcon.tsx | 27 +++++++++++++++++++ .../OrganizationSettingsSideMenu.tsx | 5 ++-- .../app/components/navigation/SideMenu.tsx | 4 +-- 3 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 apps/webapp/app/assets/icons/ChainLinkIcon.tsx diff --git a/apps/webapp/app/assets/icons/ChainLinkIcon.tsx b/apps/webapp/app/assets/icons/ChainLinkIcon.tsx new file mode 100644 index 0000000000..f2e0024547 --- /dev/null +++ b/apps/webapp/app/assets/icons/ChainLinkIcon.tsx @@ -0,0 +1,27 @@ +export function ChainLinkIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index ada3aac5e5..8e1abfa20f 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -1,5 +1,6 @@ -import { ArrowLeftIcon, LinkIcon } from "@heroicons/react/24/solid"; +import { ArrowLeftIcon } from "@heroicons/react/24/solid"; import { BellIcon } from "~/assets/icons/BellIcon"; +import { ChainLinkIcon } from "~/assets/icons/ChainLinkIcon"; import { CreditCardIcon } from "~/assets/icons/CreditCardIcon"; import { PadlockIcon } from "~/assets/icons/PadlockIcon"; import { UsageIcon } from "~/assets/icons/UsageIcon"; @@ -130,7 +131,7 @@ export function OrganizationSettingsSideMenu({ {featureFlags.hasPrivateConnections && ( From 6d701ab609883b25d4cc9a43aeb3496229a491fa Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 3 Jul 2026 14:53:53 +0100 Subject: [PATCH 15/29] feat(webapp): use the custom avatar icon for the profile picture placeholder - Replace the heroicons UserCircleIcon fallback in UserProfilePhoto/UserAvatar with the custom AvatarCircleIcon, matching the account menu Profile item - Size the account button avatar to match the popover menu item icons (20px) --- apps/webapp/app/components/UserProfilePhoto.tsx | 4 ++-- apps/webapp/app/components/navigation/SideMenu.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/components/UserProfilePhoto.tsx b/apps/webapp/app/components/UserProfilePhoto.tsx index 99febd1c24..1613417433 100644 --- a/apps/webapp/app/components/UserProfilePhoto.tsx +++ b/apps/webapp/app/components/UserProfilePhoto.tsx @@ -1,4 +1,4 @@ -import { UserCircleIcon } from "@heroicons/react/24/solid"; +import { AvatarCircleIcon } from "~/assets/icons/AvatarCircleIcon"; import { useOptionalUser } from "~/hooks/useUser"; import { cn } from "~/utils/cn"; @@ -26,6 +26,6 @@ export function UserAvatar({ />
) : ( - + ); } diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 59fb4fc3f9..ce32b956ac 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -1008,7 +1008,7 @@ function AccountMenu({ isAdmin, isImpersonating }: { isAdmin: boolean; isImperso - + } content="Account" From 347fabeb94a6a6a4e38b3dbd6d6751695c3227ec Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 3 Jul 2026 15:37:36 +0100 Subject: [PATCH 16/29] feat(webapp): redesign the account profile page with a row-based layout Adopt the Security page's row-and-divider pattern on /account: each setting is a row with the title on the left and its control on the right. Profile picture, Full name, Email address, and a "Receive onboarding emails" toggle switch sit on equal-height rows, and the Update button is now a primary button. --- .../account-profile-page-layout.md | 11 ++ .../app/routes/account._index/route.tsx | 117 ++++++++++-------- 2 files changed, 75 insertions(+), 53 deletions(-) create mode 100644 .server-changes/account-profile-page-layout.md diff --git a/.server-changes/account-profile-page-layout.md b/.server-changes/account-profile-page-layout.md new file mode 100644 index 0000000000..8c3c32c177 --- /dev/null +++ b/.server-changes/account-profile-page-layout.md @@ -0,0 +1,11 @@ +--- +area: webapp +type: improvement +--- + +Redesign the account Profile page (`/account`) to use the same row-and-divider +layout as the Security page: each setting is a full-width row with the title on +the left and its control on the right, separated by divider lines. Profile +picture, Full name, Email address, and a "Receive onboarding emails" toggle +(replacing the old checkbox) each sit on equal-height rows, and the Update +button is now a primary button. diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index b4b92c8a13..fa24fa31d7 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -3,8 +3,6 @@ import { conformZodMessage, parseWithZod } from "@conform-to/zod"; import { Form, type MetaFunction, useActionData } from "@remix-run/react"; import { type ActionFunction, json } from "@remix-run/server-runtime"; import { z } from "zod"; -import { AvatarCircleIcon } from "~/assets/icons/AvatarCircleIcon"; -import { EnvelopeIcon } from "~/assets/icons/EnvelopeIcon"; import { UserProfilePhoto } from "~/components/UserProfilePhoto"; import { MainHorizontallyCenteredContainer, @@ -12,15 +10,12 @@ import { PageContainer, } from "~/components/layout/AppLayout"; import { Button } from "~/components/primitives/Buttons"; -import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; -import { Fieldset } from "~/components/primitives/Fieldset"; -import { FormButtons } from "~/components/primitives/FormButtons"; import { FormError } from "~/components/primitives/FormError"; import { Header2 } from "~/components/primitives/Headers"; -import { Hint } from "~/components/primitives/Hint"; import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; +import { Switch } from "~/components/primitives/Switch"; import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; import { prisma } from "~/db.server"; import { useUser } from "~/hooks/useUser"; @@ -144,56 +139,72 @@ export default function Page() { - -
+ +
Profile
- - - - -
- - - - Your teammates will see this - {name.errors} - - - - - {email.errors} - - - - - {marketingEmails.errors} - - - - Update - - } - /> -
+
+
+ + + +
+ +
+
+
+
+
+ + + +
+ + {name.errors} +
+
+
+
+
+ + + +
+ + {email.errors} +
+
+
+
+
+ + + +
+ +
+
+
+
+ +
From 06458349fb094e927d5f09ad5af3ac465b935063 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 6 Jul 2026 18:13:10 +0100 Subject: [PATCH 17/29] feat(webapp): match the environment selector popover item sizing to the project menu Bump the Env popover's item icons (size-5) and labels (text-[0.90625rem]) to match the Project popover, including the preview/dev branch submenu, its branch list, and the "Manage branches" footer. Applied only at these call sites so the shared EnvironmentLabel/EnvironmentCombo defaults used across the app are unchanged. --- .../side-menu-project-and-org-menus.md | 3 ++ .../navigation/EnvironmentSelector.tsx | 52 ++++++++++++++----- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/.server-changes/side-menu-project-and-org-menus.md b/.server-changes/side-menu-project-and-org-menus.md index d64f0fd95e..57ecad6eab 100644 --- a/.server-changes/side-menu-project-and-org-menus.md +++ b/.server-changes/side-menu-project-and-org-menus.md @@ -14,6 +14,9 @@ Restructure the side menu's top-left and project/organization navigation: connections, Roles, SSO, Vercel integration, Slack integration, Switch organization, then Account and Logout) using the same icons and links as the organization settings side menu. +- Match the Environment selector popover's item sizing (icons and labels, + including the branch submenu and its footer) to the Project popover so the two + side-menu menus are visually consistent. The org loader now exposes whether the RBAC and SSO plugins are installed so the side menu can gate the Roles and SSO items the same way the settings side menu diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index a021c5b989..57a66140f2 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -35,6 +35,12 @@ import { V4Badge } from "../V4Badge"; import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu"; import { Badge } from "../primitives/Badge"; +// Size this Env popover's items to match the Project popover menu items +// (SIDE_MENU_POPOVER_ITEM_* in SideMenu.tsx). Applied only at these call sites so the +// shared EnvironmentLabel/EnvironmentCombo defaults used elsewhere in the app stay unchanged. +const ENV_POPOVER_ITEM_ICON = "size-5"; +const ENV_POPOVER_ITEM_LABEL = "text-[0.90625rem] font-medium tracking-[-0.01em]"; + export function EnvironmentSelector({ organization, project, @@ -135,7 +141,13 @@ export function EnvironmentSelector({ } + title={ + + } isSelected={env.id === environment.id} /> ); @@ -153,8 +165,12 @@ export function EnvironmentSelector({ )} title={
- - Upgrade + + Upgrade
} isSelected={false} @@ -167,8 +183,12 @@ export function EnvironmentSelector({ )} title={
- - Upgrade + + Upgrade
} isSelected={false} @@ -233,7 +253,11 @@ function Branches({ textAlignLeft fullWidth > - + - + {environment.branchName} Archived } icon={ - + } isSelected={environment.id === currentEnvironment.id} /> @@ -323,13 +349,13 @@ export function BranchesPopoverContent({ key={env.id} to={urlForEnvironment(env)} title={ - + {env.branchName ?? DEFAULT_DEV_BRANCH} } icon={ } isSelected={env.id === currentEnvironment.id} @@ -362,15 +388,17 @@ export function BranchesPopoverContent({ } + icon={} leadingIconClassName="text-text-dimmed" + className={ENV_POPOVER_ITEM_LABEL} /> ) : ( } + icon={} leadingIconClassName="text-text-dimmed" + className={ENV_POPOVER_ITEM_LABEL} /> )}
From 72cd93d64392789efe021d2b32311a908ca6b1d8 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 6 Jul 2026 18:45:38 +0100 Subject: [PATCH 18/29] feat(webapp): highlight impersonation mode in the side menu Add a yellow-500/80 right border to the side menu while impersonating and match the "Stop impersonating" text and icon to it, both driven by a single IMPERSONATION_ACCENT constant. Also consolidate this branch's webapp UI notes into one .server-changes file. --- .server-changes/account-profile-page-layout.md | 11 ----------- .../side-menu-project-and-org-menus.md | 12 +++++++++--- .../webapp/app/components/navigation/SideMenu.tsx | 15 ++++++++++++--- 3 files changed, 21 insertions(+), 17 deletions(-) delete mode 100644 .server-changes/account-profile-page-layout.md diff --git a/.server-changes/account-profile-page-layout.md b/.server-changes/account-profile-page-layout.md deleted file mode 100644 index 8c3c32c177..0000000000 --- a/.server-changes/account-profile-page-layout.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -area: webapp -type: improvement ---- - -Redesign the account Profile page (`/account`) to use the same row-and-divider -layout as the Security page: each setting is a full-width row with the title on -the left and its control on the right, separated by divider lines. Profile -picture, Full name, Email address, and a "Receive onboarding emails" toggle -(replacing the old checkbox) each sit on equal-height rows, and the Update -button is now a primary button. diff --git a/.server-changes/side-menu-project-and-org-menus.md b/.server-changes/side-menu-project-and-org-menus.md index 57ecad6eab..ab27619f19 100644 --- a/.server-changes/side-menu-project-and-org-menus.md +++ b/.server-changes/side-menu-project-and-org-menus.md @@ -3,7 +3,7 @@ area: webapp type: improvement --- -Restructure the side menu's top-left and project/organization navigation: +Refresh the side menu and account UI: - Add a new "Project" section above the "Environment" section with a popover that lists the org's projects (folder icon + checkmark for the selected one) @@ -12,11 +12,17 @@ Restructure the side menu's top-left and project/organization navigation: project/diagonal divider) and its popover is a clean list of org-level items (Settings, Usage, Billing with plan badge, Billing alerts, Team, Private connections, Roles, SSO, Vercel integration, Slack integration, Switch - organization, then Account and Logout) using the same icons and links as the - organization settings side menu. + organization), with a separate account menu button (Profile, Personal Access + Tokens, Security, admin/impersonation, Logout) beside it. - Match the Environment selector popover's item sizing (icons and labels, including the branch submenu and its footer) to the Project popover so the two side-menu menus are visually consistent. +- Redesign the account Profile page (/account) into the Security page's + row-and-divider layout: Profile picture, Full name, Email address, and a + "Receive onboarding emails" toggle on equal-height rows, with a primary Update + button. +- Signal impersonation mode with a yellow side-menu border and a matching + "Stop impersonating" accent. The org loader now exposes whether the RBAC and SSO plugins are installed so the side menu can gate the Roles and SSO items the same way the settings side menu diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index ce32b956ac..c94b952c33 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -156,6 +156,14 @@ function getSectionCollapsed( const SIDE_MENU_POPOVER_ITEM_ICON = "h-5 w-5 text-text-dimmed"; const SIDE_MENU_POPOVER_ITEM_LABEL = "text-[0.90625rem] font-medium tracking-[-0.01em]"; +// Accent used to signal impersonation mode across the UI (the side menu border and the +// "Stop impersonating" action). Full class strings per Tailwind's static scanning — change +// the shade here to update everywhere. +const IMPERSONATION_ACCENT = { + border: "border-yellow-500/80", + text: "text-yellow-500/80", +}; + type SideMenuUser = Pick< UserWithDashboardPreferences, "email" | "admin" | "dashboardPreferences" @@ -296,7 +304,8 @@ export function SideMenu({ return (
@@ -1029,7 +1038,7 @@ function AccountMenu({ isAdmin, isImpersonating }: { isAdmin: boolean; isImperso - Stop impersonating + Stop impersonating @@ -1038,7 +1047,7 @@ function AccountMenu({ isAdmin, isImpersonating }: { isAdmin: boolean; isImperso } icon={UserCrossIcon} onClick={stopImpersonating} - leadingIconClassName={cn(SIDE_MENU_POPOVER_ITEM_ICON, "text-amber-400")} + leadingIconClassName={cn(SIDE_MENU_POPOVER_ITEM_ICON, IMPERSONATION_ACCENT.text)} className={SIDE_MENU_POPOVER_ITEM_LABEL} /> ) : ( From c7e77668f334e787b805316967800f0d80f035b2 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 6 Jul 2026 18:53:58 +0100 Subject: [PATCH 19/29] feat(webapp): move Settings to the top of the organization settings side menu --- .../side-menu-project-and-org-menus.md | 1 + .../navigation/OrganizationSettingsSideMenu.tsx | 16 ++++++++-------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.server-changes/side-menu-project-and-org-menus.md b/.server-changes/side-menu-project-and-org-menus.md index ab27619f19..362050e2a6 100644 --- a/.server-changes/side-menu-project-and-org-menus.md +++ b/.server-changes/side-menu-project-and-org-menus.md @@ -23,6 +23,7 @@ Refresh the side menu and account UI: button. - Signal impersonation mode with a yellow side-menu border and a matching "Stop impersonating" accent. +- Move the Settings item to the top of the organization settings side menu. The org loader now exposes whether the RBAC and SSO plugins are installed so the side menu can gate the Roles and SSO items the same way the settings side menu diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index 8e1abfa20f..8960337101 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -85,6 +85,14 @@ export function OrganizationSettingsSideMenu({
+ {isManagedCloud && ( <> )} -
From ea625382e003d54bef3fef325111dbaf81e667f0 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 6 Jul 2026 19:07:08 +0100 Subject: [PATCH 20/29] feat(webapp): align org and account side menu padding with the main side menu Match the organization settings and account side menus' horizontal padding (pl-2.5, pr-0, stable scrollbar gutter) to the main side menu. Add a nameClassName prop to SideMenuItem and tighten the "Personal Access Tokens" label's tracking so it no longer truncates. --- .server-changes/side-menu-project-and-org-menus.md | 3 +++ .../webapp/app/components/navigation/AccountSideMenu.tsx | 3 ++- .../navigation/OrganizationSettingsSideMenu.tsx | 2 +- apps/webapp/app/components/navigation/SideMenuItem.tsx | 9 ++++++++- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.server-changes/side-menu-project-and-org-menus.md b/.server-changes/side-menu-project-and-org-menus.md index 362050e2a6..cbc3bb4d9f 100644 --- a/.server-changes/side-menu-project-and-org-menus.md +++ b/.server-changes/side-menu-project-and-org-menus.md @@ -24,6 +24,9 @@ Refresh the side menu and account UI: - Signal impersonation mode with a yellow side-menu border and a matching "Stop impersonating" accent. - Move the Settings item to the top of the organization settings side menu. +- Align the organization settings and account side menus' horizontal padding + with the main side menu, and tighten the "Personal Access Tokens" label so it + no longer truncates. The org loader now exposes whether the RBAC and SSO plugins are installed so the side menu can gate the Roles and SSO items the same way the settings side menu diff --git a/apps/webapp/app/components/navigation/AccountSideMenu.tsx b/apps/webapp/app/components/navigation/AccountSideMenu.tsx index 10c7a88684..d05d5a0bc1 100644 --- a/apps/webapp/app/components/navigation/AccountSideMenu.tsx +++ b/apps/webapp/app/components/navigation/AccountSideMenu.tsx @@ -34,7 +34,7 @@ export function AccountSideMenu({ user }: { user: User }) { Back to app
-
+
Back to app
-
+
diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx index f2349fc1e6..4a01c5f8b2 100644 --- a/apps/webapp/app/components/navigation/SideMenuItem.tsx +++ b/apps/webapp/app/components/navigation/SideMenuItem.tsx @@ -14,6 +14,7 @@ export function SideMenuItem({ trailingIcon, trailingIconClassName, name, + nameClassName, to, badge, target, @@ -30,6 +31,7 @@ export function SideMenuItem({ trailingIcon?: RenderIcon; trailingIconClassName?: string; name: string; + nameClassName?: string; to: string; badge?: ReactNode; target?: AnchorHTMLAttributes["target"]; @@ -83,7 +85,12 @@ export function SideMenuItem({ }} transition={{ duration: 0.2, ease: "easeOut" }} > - + {name} {badge && !isCollapsed && ( From 08c2ab7249430f283d8ba365670d33b479675bf8 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 6 Jul 2026 19:23:07 +0100 Subject: [PATCH 21/29] fix(webapp): match Help & Feedback popover Shortcuts and Contact us items to the other entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Shortcuts" and "Contact us…" entries used Button variant="small-menu-item" (bright, smaller text, misaligned icon) while the other entries use SideMenuItem. Add a shared SideMenuItemButton that mirrors SideMenuItem's styling and works as a Radix asChild dialog/sheet trigger, and use it for both entries. --- .../side-menu-project-and-org-menus.md | 3 ++ apps/webapp/app/components/Shortcuts.tsx | 16 +------- .../navigation/HelpAndFeedbackPopover.tsx | 17 +++------ .../components/navigation/SideMenuItem.tsx | 37 ++++++++++++++++++- 4 files changed, 46 insertions(+), 27 deletions(-) diff --git a/.server-changes/side-menu-project-and-org-menus.md b/.server-changes/side-menu-project-and-org-menus.md index cbc3bb4d9f..ac3a6ef23f 100644 --- a/.server-changes/side-menu-project-and-org-menus.md +++ b/.server-changes/side-menu-project-and-org-menus.md @@ -27,6 +27,9 @@ Refresh the side menu and account UI: - Align the organization settings and account side menus' horizontal padding with the main side menu, and tighten the "Personal Access Tokens" label so it no longer truncates. +- Restyle the "Shortcuts" and "Contact us…" entries in the Help & Feedback + popover to match the other menu items (icon size/alignment, dimmed text, text + size). The org loader now exposes whether the RBAC and SSO plugins are installed so the side menu can gate the Roles and SSO items the same way the settings side menu diff --git a/apps/webapp/app/components/Shortcuts.tsx b/apps/webapp/app/components/Shortcuts.tsx index 63b5fddf71..0ba1f65c20 100644 --- a/apps/webapp/app/components/Shortcuts.tsx +++ b/apps/webapp/app/components/Shortcuts.tsx @@ -1,8 +1,8 @@ import { KeyboardIcon } from "~/assets/icons/KeyboardIcon"; import { useState } from "react"; import { useShortcutKeys } from "~/hooks/useShortcutKeys"; -import { Button } from "./primitives/Buttons"; import { Header3 } from "./primitives/Headers"; +import { SideMenuItemButton } from "./navigation/SideMenuItem"; import { Paragraph } from "./primitives/Paragraph"; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "./primitives/SheetV3"; import { ShortcutKey } from "./primitives/ShortcutKey"; @@ -11,19 +11,7 @@ export function Shortcuts() { return ( - + diff --git a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx index b329f9c95e..91a0a8ddb9 100644 --- a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx +++ b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx @@ -13,12 +13,11 @@ import { useRecentChangelogs } from "~/routes/resources.platform-changelogs"; import { cn } from "~/utils/cn"; import { Feedback } from "../Feedback"; import { Shortcuts } from "../Shortcuts"; -import { Button } from "../primitives/Buttons"; import { Paragraph } from "../primitives/Paragraph"; import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover"; import { ShortcutKey } from "../primitives/ShortcutKey"; import { SimpleTooltip } from "../primitives/Tooltip"; -import { SideMenuItem } from "./SideMenuItem"; +import { SideMenuItem, SideMenuItemButton } from "./SideMenuItem"; export function HelpAndFeedback({ disableShortcut = false, @@ -125,17 +124,11 @@ export function HelpAndFeedback({ - Contact us… - + /> } />
diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx index 4a01c5f8b2..4dc955d260 100644 --- a/apps/webapp/app/components/navigation/SideMenuItem.tsx +++ b/apps/webapp/app/components/navigation/SideMenuItem.tsx @@ -1,4 +1,9 @@ -import { type AnchorHTMLAttributes, type ReactNode } from "react"; +import { + type AnchorHTMLAttributes, + type ButtonHTMLAttributes, + forwardRef, + type ReactNode, +} from "react"; import { Link } from "@remix-run/react"; import { motion } from "framer-motion"; import { usePathName } from "~/hooks/usePathName"; @@ -161,3 +166,33 @@ export function SideMenuItem({ /> ); } + +/** + * A button styled to match {@link SideMenuItem}, for menu entries that open a + * dialog/sheet rather than navigate. Forwards its ref and props so it can be + * used as a Radix `asChild` trigger. + */ +export const SideMenuItemButton = forwardRef< + HTMLButtonElement, + { icon: RenderIcon; name: string } & ButtonHTMLAttributes +>(function SideMenuItemButton({ icon, name, className, type, ...props }, ref) { + return ( + + ); +}); From 1a574d7a1aa55cd517e33f23e94311fbbad9066a Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 7 Jul 2026 09:48:44 +0100 Subject: [PATCH 22/29] fix(webapp): keep the keyboard shortcut hint on the Help & Feedback Shortcuts item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The popover restyle dropped the "⇧ ?" hint that the old button rendered via its shortcut prop. Add a trailing slot to SideMenuItemButton and pass the ShortcutKey so the hint is shown again. --- apps/webapp/app/components/Shortcuts.tsx | 7 ++++++- apps/webapp/app/components/navigation/SideMenuItem.tsx | 7 ++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/components/Shortcuts.tsx b/apps/webapp/app/components/Shortcuts.tsx index 0ba1f65c20..87e5d08f37 100644 --- a/apps/webapp/app/components/Shortcuts.tsx +++ b/apps/webapp/app/components/Shortcuts.tsx @@ -11,7 +11,12 @@ export function Shortcuts() { return ( - + } + /> diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx index 4dc955d260..2f81ef63d5 100644 --- a/apps/webapp/app/components/navigation/SideMenuItem.tsx +++ b/apps/webapp/app/components/navigation/SideMenuItem.tsx @@ -174,8 +174,8 @@ export function SideMenuItem({ */ export const SideMenuItemButton = forwardRef< HTMLButtonElement, - { icon: RenderIcon; name: string } & ButtonHTMLAttributes ->(function SideMenuItemButton({ icon, name, className, type, ...props }, ref) { + { icon: RenderIcon; name: string; trailing?: ReactNode } & ButtonHTMLAttributes +>(function SideMenuItemButton({ icon, name, trailing, className, type, ...props }, ref) { return ( ); }); From 71c540b94d74735d6343b1c17e0c2a36d32d88f1 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 10 Jul 2026 13:32:11 +0100 Subject: [PATCH 23/29] feat(webapp): make the side menu resizable by dragging its right edge Drag the side menu right border to set a custom width, remembered per user in dashboard preferences. Dragging below the default width fades the labels, section headers, badges, and item actions in real time and, on release, snaps back open or collapses depending on how far it was dragged. A plain click on the edge toggles collapsed/expanded (Cmd+B still works) and a pointer-following tooltip explains both gestures. The drag runs on window-level pointer listeners so releasing anywhere, including past the collapsed width or outside the window, always finalizes the resize. The initial width and collapse CSS variables are server-rendered to avoid an expanded-state flash before hydration. Also adds the missing asChild to the account menu tooltip trigger, fixing nested buttons that made React discard the server-rendered document on every page load. --- .../side-menu-project-and-org-menus.md | 5 + .../navigation/EnvironmentSelector.tsx | 17 +- .../app/components/navigation/SideMenu.tsx | 431 +++++++++++++++++- .../components/navigation/SideMenuHeader.tsx | 12 +- .../components/navigation/SideMenuItem.tsx | 52 ++- .../components/navigation/SideMenuSection.tsx | 36 +- .../routes/resources.preferences.sidemenu.tsx | 2 + .../services/dashboardPreferences.server.ts | 12 +- 8 files changed, 490 insertions(+), 77 deletions(-) diff --git a/.server-changes/side-menu-project-and-org-menus.md b/.server-changes/side-menu-project-and-org-menus.md index ac3a6ef23f..d073d58ccc 100644 --- a/.server-changes/side-menu-project-and-org-menus.md +++ b/.server-changes/side-menu-project-and-org-menus.md @@ -30,6 +30,11 @@ Refresh the side menu and account UI: - Restyle the "Shortcuts" and "Contact us…" entries in the Help & Feedback popover to match the other menu items (icon size/alignment, dimmed text, text size). +- Make the main side menu resizable: drag its right edge to set a custom width + (remembered per user), with the labels, headers, and padding transitioning in + real time and a snap to open or collapsed when released below the default + width. Clicking the edge toggles the menu and a tooltip explains both + gestures. The org loader now exposes whether the RBAC and SSO plugins are installed so the side menu can gate the Roles and SSO items the same way the settings side menu diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index 57a66140f2..6af8e963f0 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -83,11 +83,18 @@ export function EnvironmentSelector({ isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100" )} > - + {/* + Inner opacity is driven by the resizable SideMenu's `--sm-label-opacity` + variable so the label fades as the menu is dragged narrower. Unset in the other + places this selector is used (blank-state panels, Limits page) → falls back to 1. + */} + + + ((ax * u + bx) * u + cx) * u; + const sampleY = (u: number) => ((ay * u + by) * u + cy) * u; + const sampleDerivativeX = (u: number) => (3 * ax * u + 2 * bx) * u + cx; + // Newton-Raphson to invert x(u) = t. + let u = t; + for (let i = 0; i < 6; i++) { + const x = sampleX(u) - t; + const dx = sampleDerivativeX(u); + if (Math.abs(x) < 1e-4 || Math.abs(dx) < 1e-6) break; + u -= x / dx; + } + return sampleY(clamp(u, 0, 1)); +} + type SideMenuUser = Pick< UserWithDashboardPreferences, "email" | "admin" | "dashboardPreferences" @@ -196,9 +286,43 @@ export function SideMenu({ const [isCollapsed, setIsCollapsed] = useState( user.dashboardPreferences.sideMenu?.isCollapsed ?? false ); + const [isDragging, setIsDragging] = useState(false); + + // --- Resize state (see the module constants above) --- + const rootRef = useRef(null); + const rafRef = useRef(null); + // Mirror of `isCollapsed` for the drag handlers, which live outside React's render cycle and + // must never act on a stale closure. + const isCollapsedRef = useRef(isCollapsed); + // The last-committed expanded width; animation targets and re-expansion read from here. + const expandedWidthRef = useRef( + clamp(user.dashboardPreferences.sideMenu?.width ?? DEFAULT_WIDTH, DEFAULT_WIDTH, MAX_WIDTH) + ); + // Frozen initial width for the first paint. It never changes across renders, so React sets it + // once and never fights the imperative width writes that drive the drag/animation. + const initialWidthRef = useRef( + (user.dashboardPreferences.sideMenu?.isCollapsed ?? false) + ? COLLAPSED_WIDTH + : expandedWidthRef.current + ); + const widthRef = useRef(initialWidthRef.current); + const progressRef = useRef((user.dashboardPreferences.sideMenu?.isCollapsed ?? false) ? 1 : 0); + // Frozen initial style, including the CSS variables, so the server-rendered HTML already carries + // the correct collapsed/expanded visuals (no expanded-state flash before hydration). The object + // identity never changes, so React never rewrites these after writeVisual takes over the DOM. + const initialStyleRef = useRef({ + width: initialWidthRef.current, + "--sm-collapse": String(progressRef.current), + "--sm-label-opacity": String(progressToLabelOpacity(progressRef.current)), + } as CSSProperties); + // Removes the window-level listeners of an in-flight drag (set on pointerdown, cleared when the + // drag finishes or the component unmounts). + const dragCleanupRef = useRef<(() => void) | null>(null); + const preferencesFetcher = useFetcher(); const pendingPreferencesRef = useRef<{ isCollapsed?: boolean; + width?: number; sectionId?: SideMenuSectionId; sectionCollapsed?: boolean; }>({}); @@ -215,6 +339,7 @@ export function SideMenu({ const persistSideMenuPreferences = useCallback( (data: { isCollapsed?: boolean; + width?: number; sectionId?: SideMenuSectionId; sectionCollapsed?: boolean; }) => { @@ -238,6 +363,9 @@ export function SideMenu({ if (pending.isCollapsed !== undefined) { formData.append("isCollapsed", String(pending.isCollapsed)); } + if (pending.width !== undefined) { + formData.append("width", String(pending.width)); + } if (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined) { formData.append("sectionId", pending.sectionId); formData.append("sectionCollapsed", String(pending.sectionCollapsed)); @@ -262,6 +390,7 @@ export function SideMenu({ const pending = pendingPreferencesRef.current; const hasPendingChanges = pending.isCollapsed !== undefined || + pending.width !== undefined || (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined); if (hasPendingChanges) { @@ -269,6 +398,9 @@ export function SideMenu({ if (pending.isCollapsed !== undefined) { formData.append("isCollapsed", String(pending.isCollapsed)); } + if (pending.width !== undefined) { + formData.append("width", String(pending.width)); + } if (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined) { formData.append("sectionId", pending.sectionId); formData.append("sectionCollapsed", String(pending.sectionCollapsed)); @@ -282,11 +414,178 @@ export function SideMenu({ }; }, [preferencesFetcher, user.isImpersonating]); - const handleToggleCollapsed = () => { - const newIsCollapsed = !isCollapsed; - setIsCollapsed(newIsCollapsed); - persistSideMenuPreferences({ isCollapsed: newIsCollapsed }); - }; + // Write the width + collapse variables straight to the DOM (no React re-render) so a drag stays + // smooth. Everything width-driven (labels, headers, padding, dividers) reads these variables. + const writeVisual = useCallback((width: number, progress: number) => { + widthRef.current = width; + progressRef.current = progress; + const el = rootRef.current; + if (!el) return; + el.style.width = `${width}px`; + el.style.setProperty("--sm-collapse", String(progress)); + el.style.setProperty("--sm-label-opacity", String(progressToLabelOpacity(progress))); + }, []); + + // Animate width + progress together over COLLAPSE_ANIM_MS with the standard easing (used for the + // toggle button, the ⌘B shortcut, and the release-snap). + const animateTo = useCallback( + (targetWidth: number, targetProgress: number) => { + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + const startWidth = widthRef.current; + const startProgress = progressRef.current; + if (startWidth === targetWidth && startProgress === targetProgress) return; + const startTime = performance.now(); + const step = (now: number) => { + const t = clamp((now - startTime) / COLLAPSE_ANIM_MS, 0, 1); + const eased = easeStandard(t); + writeVisual( + startWidth + (targetWidth - startWidth) * eased, + startProgress + (targetProgress - startProgress) * eased + ); + if (t < 1) { + rafRef.current = requestAnimationFrame(step); + } else { + rafRef.current = null; + writeVisual(targetWidth, targetProgress); + } + }; + rafRef.current = requestAnimationFrame(step); + }, + [writeVisual] + ); + + // Collapse/expand to a resting state and remember it. + const applyCollapsed = useCallback( + (next: boolean) => { + isCollapsedRef.current = next; + setIsCollapsed(next); + persistSideMenuPreferences({ isCollapsed: next }); + animateTo(next ? COLLAPSED_WIDTH : expandedWidthRef.current, next ? 1 : 0); + }, + [animateTo, persistSideMenuPreferences] + ); + + const handleToggleCollapsed = useCallback(() => { + applyCollapsed(!isCollapsedRef.current); + }, [applyCollapsed]); + + // The whole drag lives in window-level listeners installed here, so releasing the pointer + // anywhere — outside the handle, past the menu's min width, even outside the window — always + // finalizes the drag. (Pointer capture alone proved unreliable: if the browser drops it + // mid-drag, the release handler never fires and the menu is left stranded mid-resize.) + const onHandlePointerDown = useCallback( + (e: ReactPointerEvent) => { + if (e.button !== 0) return; + e.preventDefault(); + // Capture keeps hover states elsewhere quiet while dragging; the drag itself does not + // depend on it (and must not die if capture is unavailable for this pointer). + try { + e.currentTarget.setPointerCapture(e.pointerId); + } catch {} + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + // Never allow two concurrent drags. + dragCleanupRef.current?.(); + + const drag = { + startX: e.clientX, + startWidth: rootRef.current?.getBoundingClientRect().width ?? widthRef.current, + didDrag: false, + }; + + const cleanup = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onCancel); + window.removeEventListener("blur", onCancel); + document.body.style.userSelect = ""; + document.body.style.cursor = ""; + dragCleanupRef.current = null; + }; + + const onMove = (ev: PointerEvent) => { + const dx = ev.clientX - drag.startX; + if (!drag.didDrag) { + // Ignore tiny movement so a click still reads as a click (toggle), not a drag. + if (Math.abs(dx) < DRAG_CLICK_THRESHOLD) return; + drag.didDrag = true; + setIsDragging(true); + document.body.style.userSelect = "none"; + document.body.style.cursor = "col-resize"; + } + const width = clamp(drag.startWidth + dx, COLLAPSED_WIDTH, MAX_WIDTH); + writeVisual(width, widthToProgress(width)); + }; + + const onUp = () => { + cleanup(); + setIsDragging(false); + + // A press with no meaningful drag toggles the menu. + if (!drag.didDrag) { + applyCollapsed(!isCollapsedRef.current); + return; + } + + const width = widthRef.current; + if (width >= DEFAULT_WIDTH) { + // Rest at the dragged width. + const rounded = Math.round(width); + expandedWidthRef.current = rounded; + isCollapsedRef.current = false; + setIsCollapsed(false); + persistSideMenuPreferences({ isCollapsed: false, width: rounded }); + writeVisual(rounded, 0); + } else if (widthToProgress(width) <= COLLAPSE_SNAP_THRESHOLD) { + // Released near the default width — spring back open. + expandedWidthRef.current = DEFAULT_WIDTH; + isCollapsedRef.current = false; + setIsCollapsed(false); + persistSideMenuPreferences({ isCollapsed: false, width: DEFAULT_WIDTH }); + animateTo(DEFAULT_WIDTH, 0); + } else { + // Released deeper in (including over-drags past the min width) — collapse the rest of + // the way. + isCollapsedRef.current = true; + setIsCollapsed(true); + persistSideMenuPreferences({ isCollapsed: true }); + animateTo(COLLAPSED_WIDTH, 1); + } + }; + + const onCancel = () => { + cleanup(); + setIsDragging(false); + if (!drag.didDrag) return; + // Settle back to the current resting state. + animateTo( + isCollapsedRef.current ? COLLAPSED_WIDTH : expandedWidthRef.current, + isCollapsedRef.current ? 1 : 0 + ); + }; + + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onCancel); + window.addEventListener("blur", onCancel); + dragCleanupRef.current = cleanup; + }, + [animateTo, applyCollapsed, persistSideMenuPreferences, writeVisual] + ); + + // Keep the drag handlers' mirror of the collapsed state in sync, and tear down any in-flight + // animation/drag listeners on unmount. + useEffect(() => { + isCollapsedRef.current = isCollapsed; + }, [isCollapsed]); + useEffect(() => { + return () => { + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + dragCleanupRef.current?.(); + }; + }, []); /** Generic handler for any collapsible section - just pass the section ID */ const handleSectionToggle = useCallback( @@ -303,12 +602,18 @@ export function SideMenu({ return (
+
@@ -323,10 +628,8 @@ export function SideMenu({
@@ -388,10 +691,8 @@ export function SideMenu({ )} >
- + {organization.title} @@ -1023,6 +1327,7 @@ function AccountMenu({ isAdmin, isImpersonating }: { isAdmin: boolean; isImperso content="Account" side="bottom" sideOffset={8} + asChild disableHoverableContent /> - + {project.name ?? "Select a project"} @@ -1393,7 +1701,11 @@ function Integrations({ organization }: { organization: MatchedOrganization }) { ); } -/** Helper component that fades out but preserves width (collapses to 0 width) */ +/** + * Helper component that fades out but preserves width (collapses to 0 width). The fade is driven + * by the menu's `--sm-label-opacity` variable so it tracks a drag in real time (only max-width + * transitions via CSS — transitioning the opacity too would lag the per-frame variable writes). + */ function CollapsibleElement({ isCollapsed, children, @@ -1406,10 +1718,11 @@ function CollapsibleElement({ return (
{children}
@@ -1527,3 +1840,77 @@ function CollapseMenuButton({
); } + +/** + * The resize affordance straddling the side menu's right border. Hovering fades in a 3px indigo + * line (matching the app's ResizableHandle, with soft gradient ends), dragging resizes the menu, + * and a plain click toggles it collapsed/expanded. The tooltip follows the pointer's vertical + * position and explains both gestures. + * + * The strip extends 4px past the menu's edge; the menu root deliberately has no overflow-hidden + * (only its inner grid does), so nothing clips the outer half. + */ +function ResizeHandle({ + isCollapsed, + isDragging, + onPointerDown, +}: { + isCollapsed: boolean; + isDragging: boolean; + onPointerDown: (e: ReactPointerEvent) => void; +}) { + // Fully controlled so the open state never switches between controlled and uncontrolled + // mid-interaction; open requests made while dragging are dropped. + const [isTooltipOpen, setTooltipOpen] = useState(false); + // The pointer's Y offset within the strip — anchors the tooltip beside the cursor instead of at + // the strip's vertical center. + const [anchorY, setAnchorY] = useState(0); + + return ( + + setTooltipOpen(open && !isDragging)} + > + +
{ + if (isDragging) return; + setAnchorY(Math.round(e.clientY - e.currentTarget.getBoundingClientRect().top)); + }} + className="group/resize absolute inset-y-0 -right-1 z-30 w-2 cursor-col-resize touch-none" + > +
+
+ + + Drag to resize + + {isCollapsed ? "Click to expand" : "Click to collapse"} + + + + + + + + + ); +} diff --git a/apps/webapp/app/components/navigation/SideMenuHeader.tsx b/apps/webapp/app/components/navigation/SideMenuHeader.tsx index 8d975cba43..0361c409a5 100644 --- a/apps/webapp/app/components/navigation/SideMenuHeader.tsx +++ b/apps/webapp/app/components/navigation/SideMenuHeader.tsx @@ -40,15 +40,9 @@ export function SideMenuHeader({

{visiblePart} {fadingPart && ( - - {fadingPart} - + // Driven by the resizable SideMenu's `--sm-label-opacity` variable so "Project" morphs to + // "Proj" in real time as the menu is dragged narrower. Unset elsewhere → falls back to 1. + {fadingPart} )}

{children !== undefined ? ( diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx index 2f81ef63d5..a3878b9fe3 100644 --- a/apps/webapp/app/components/navigation/SideMenuItem.tsx +++ b/apps/webapp/app/components/navigation/SideMenuItem.tsx @@ -82,37 +82,40 @@ export function SideMenuItem({ )} /> - - {name} - - {badge && !isCollapsed && ( - - {badge} - - )} - {trailingIcon && !isCollapsed && ( - - )} + {name} + + {badge && !isCollapsed && ( +
{badge}
+ )} + {trailingIcon && !isCollapsed && ( + + )} +
); @@ -140,11 +143,14 @@ export function SideMenuItem({ disableHoverableContent /> {!isCollapsed && ( + // Fades with the labels via the resizable SideMenu's `--sm-label-opacity` variable + // (falls back to fully visible when the variable is unset).
{action}
diff --git a/apps/webapp/app/components/navigation/SideMenuSection.tsx b/apps/webapp/app/components/navigation/SideMenuSection.tsx index 1564716512..bfcca9a3ca 100644 --- a/apps/webapp/app/components/navigation/SideMenuSection.tsx +++ b/apps/webapp/app/components/navigation/SideMenuSection.tsx @@ -38,16 +38,19 @@ export function SideMenuSection({
{/* Header container - stays in DOM to preserve height */}
- {/* Header - fades out when sidebar is collapsed */} -

{title}

@@ -60,15 +63,14 @@ export function SideMenuSection({
{headerAction &&
{headerAction}
} -
- {/* Divider - absolutely positioned, visible when sidebar is collapsed but section is expanded */} - + {/* + Divider - absolutely positioned, fades in as the header fades out (driven by the + `--sm-collapse` progress variable, 0 → 1). Only shown while this section is expanded. + */} +
diff --git a/apps/webapp/app/routes/resources.preferences.sidemenu.tsx b/apps/webapp/app/routes/resources.preferences.sidemenu.tsx index 94db448b58..4e43269d0e 100644 --- a/apps/webapp/app/routes/resources.preferences.sidemenu.tsx +++ b/apps/webapp/app/routes/resources.preferences.sidemenu.tsx @@ -15,6 +15,7 @@ const booleanFromFormData = z const RequestSchema = z.object({ isCollapsed: booleanFromFormData, + width: z.coerce.number().int().positive().optional(), sectionId: SideMenuSectionIdSchema.optional(), sectionCollapsed: booleanFromFormData, // Generic item order fields @@ -66,6 +67,7 @@ export async function action({ request }: ActionFunctionArgs) { await updateSideMenuPreferences({ user, isCollapsed: result.data.isCollapsed, + width: result.data.width, sectionCollapsed, }); diff --git a/apps/webapp/app/services/dashboardPreferences.server.ts b/apps/webapp/app/services/dashboardPreferences.server.ts index 7af007dc38..e4ce1cd91d 100644 --- a/apps/webapp/app/services/dashboardPreferences.server.ts +++ b/apps/webapp/app/services/dashboardPreferences.server.ts @@ -5,6 +5,8 @@ import { type UserFromSession } from "./session.server"; const SideMenuPreferences = z.object({ isCollapsed: z.boolean().default(false), + /** The expanded (non-collapsed) width of the side menu in pixels, set by dragging the resize handle. */ + width: z.number().optional(), // Map for section collapsed states - keys are section identifiers collapsedSections: z.record(z.string(), z.boolean()).optional(), /** Organization-specific settings */ @@ -124,10 +126,13 @@ export async function clearCurrentProject({ user }: { user: UserFromSession }) { export async function updateSideMenuPreferences({ user, isCollapsed, + width, sectionCollapsed, }: { user: UserFromSession; isCollapsed?: boolean; + /** The expanded width of the side menu in pixels (from the resize handle) */ + width?: number; /** Update a specific section's collapsed state */ sectionCollapsed?: { sectionId: SideMenuSectionId; collapsed: boolean }; }) { @@ -148,6 +153,7 @@ export async function updateSideMenuPreferences({ const updatedSideMenu = SideMenuPreferences.parse({ ...currentSideMenu, ...(isCollapsed !== undefined && { isCollapsed }), + ...(width !== undefined && { width }), collapsedSections: updatedCollapsedSections, }); @@ -156,7 +162,11 @@ export async function updateSideMenuPreferences({ JSON.stringify(updatedSideMenu.collapsedSections) !== JSON.stringify(currentSideMenu.collapsedSections); - if (updatedSideMenu.isCollapsed === currentSideMenu.isCollapsed && !hasCollapsedSectionsChanged) { + if ( + updatedSideMenu.isCollapsed === currentSideMenu.isCollapsed && + updatedSideMenu.width === currentSideMenu.width && + !hasCollapsedSectionsChanged + ) { return; } From 11efa9538a3deef3de5c8c6ef6e74785955a85ab Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 10 Jul 2026 18:06:39 +0100 Subject: [PATCH 24/29] feat(webapp): move Ask AI into the Help & Feedback menu Ask AI now sits at the top of the Help & Feedback popover with its Cmd+I shortcut, instead of a separate footer button. The dialog and shortcut are hosted around the popover so they keep working after it closes, and the duplicate footer button is removed from the account and organization settings side menus. --- apps/webapp/app/components/AskAI.tsx | 134 +++++++--- .../components/navigation/AccountSideMenu.tsx | 2 - .../navigation/HelpAndFeedbackPopover.tsx | 249 ++++++++++-------- .../OrganizationSettingsSideMenu.tsx | 2 - 4 files changed, 243 insertions(+), 144 deletions(-) diff --git a/apps/webapp/app/components/AskAI.tsx b/apps/webapp/app/components/AskAI.tsx index bf76654cce..c0ea3922a0 100644 --- a/apps/webapp/app/components/AskAI.tsx +++ b/apps/webapp/app/components/AskAI.tsx @@ -11,11 +11,12 @@ import { useSearchParams } from "@remix-run/react"; import DOMPurify from "dompurify"; import { motion } from "framer-motion"; import { marked } from "marked"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; import { useTypedRouteLoaderData } from "remix-typedjson"; import { AISparkleIcon } from "~/assets/icons/AISparkleIcon"; import { SparkleListIcon } from "~/assets/icons/SparkleListIcon"; import { useFeatures } from "~/hooks/useFeatures"; +import { useShortcutKeys } from "~/hooks/useShortcutKeys"; import { type loader } from "~/root"; import { Button } from "./primitives/Buttons"; import { Callout } from "./primitives/Callout"; @@ -38,6 +39,105 @@ function useKapaWebsiteId() { return routeMatch?.kapa.websiteId; } +/** Open/close state for the Ask AI dialog, including the `?aiHelp=` deep-link handling. */ +function useAskAIState() { + const [isOpen, setIsOpen] = useState(false); + const [initialQuery, setInitialQuery] = useState(); + const [searchParams, setSearchParams] = useSearchParams(); + + const openAskAI = useCallback((question?: string) => { + if (question) { + setInitialQuery(question); + } else { + setInitialQuery(undefined); + } + setIsOpen(true); + }, []); + + const closeAskAI = useCallback(() => { + setIsOpen(false); + setInitialQuery(undefined); + }, []); + + // Handle URL param functionality + useEffect(() => { + const aiHelp = searchParams.get("aiHelp"); + if (aiHelp) { + // Delay to avoid hCaptcha bot detection + window.setTimeout(() => openAskAI(aiHelp), 1000); + + // Clone instead of mutating in place + const next = new URLSearchParams(searchParams); + next.delete("aiHelp"); + setSearchParams(next); + } + }, [searchParams, openAskAI]); + + return { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI }; +} + +/** + * Hosts Ask AI for a menu that renders its own trigger (the side menu's Help & Feedback popover): + * the Kapa provider, the global ⌘I shortcut, and the dialog all live here, and `children` + * receives the open function to render a trigger with. Wrap this around the popover rather than + * inside its content so the dialog and shortcut survive the popover closing. `children` receives + * undefined when Ask AI is unavailable (self-hosted, no Kapa website id, or during SSR). + */ +export function AskAIRoot({ + children, +}: { + children: (openAskAI: (() => void) | undefined) => ReactNode; +}) { + const { isManagedCloud } = useFeatures(); + const websiteId = useKapaWebsiteId(); + + if (!isManagedCloud || !websiteId) { + return <>{children(undefined)}; + } + + return ( + {children(undefined)}}> + {() => {children}} + + ); +} + +function AskAIRootProvider({ + websiteId, + children, +}: { + websiteId: string; + children: (openAskAI: () => void) => ReactNode; +}) { + const { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI } = useAskAIState(); + + useShortcutKeys({ + shortcut: { modifiers: ["mod"], key: "i", enabledOnInputElements: true }, + action: () => openAskAI(), + }); + + return ( + openAskAI(), + onAnswerGenerationCompleted: () => openAskAI(), + }, + }} + botProtectionMechanism="hcaptcha" + > + {children(() => openAskAI())} + + + ); +} + export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) { const { isManagedCloud } = useFeatures(); const websiteId = useKapaWebsiteId(); @@ -72,37 +172,7 @@ type AskAIProviderProps = { }; function AskAIProvider({ websiteId, isCollapsed = false }: AskAIProviderProps) { - const [isOpen, setIsOpen] = useState(false); - const [initialQuery, setInitialQuery] = useState(); - const [searchParams, setSearchParams] = useSearchParams(); - - const openAskAI = useCallback((question?: string) => { - if (question) { - setInitialQuery(question); - } else { - setInitialQuery(undefined); - } - setIsOpen(true); - }, []); - - const closeAskAI = useCallback(() => { - setIsOpen(false); - setInitialQuery(undefined); - }, []); - - // Handle URL param functionality - useEffect(() => { - const aiHelp = searchParams.get("aiHelp"); - if (aiHelp) { - // Delay to avoid hCaptcha bot detection - window.setTimeout(() => openAskAI(aiHelp), 1000); - - // Clone instead of mutating in place - const next = new URLSearchParams(searchParams); - next.delete("aiHelp"); - setSearchParams(next); - } - }, [searchParams, openAskAI]); + const { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI } = useAskAIState(); return (
-
); diff --git a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx index 91a0a8ddb9..4390c0fca6 100644 --- a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx +++ b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx @@ -1,6 +1,7 @@ import { ArrowUpRightIcon } from "@heroicons/react/20/solid"; import { motion } from "framer-motion"; import { Fragment, useState } from "react"; +import { AISparkleIcon } from "~/assets/icons/AISparkleIcon"; import { BookIcon } from "~/assets/icons/BookIcon"; import { BulbIcon } from "~/assets/icons/BulbIcon"; import { EnvelopeIcon } from "~/assets/icons/EnvelopeIcon"; @@ -11,6 +12,7 @@ import { useShortcutKeys } from "~/hooks/useShortcutKeys"; import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route"; import { useRecentChangelogs } from "~/routes/resources.platform-changelogs"; import { cn } from "~/utils/cn"; +import { AskAIRoot } from "../AskAI"; import { Feedback } from "../Feedback"; import { Shortcuts } from "../Shortcuts"; import { Paragraph } from "../primitives/Paragraph"; @@ -47,121 +49,152 @@ export function HelpAndFeedback({ - - - - - + {(openAskAI) => ( + + + + + {/* + Fades via the resizable side menu's `--sm-label-opacity` variable (falls back to + fully visible when unset) and truncates as the menu narrows, matching the nav + items. Only max-width and color transition via CSS so the per-frame + variable-driven opacity is not lagged. + */} + + Help & Feedback + + + + } + content={ + Help & Feedback + - - - } - content={ - - Help & Feedback - - - } - side="right" - sideOffset={8} - delayDuration={isCollapsed ? 0 : 500} - buttonClassName="!h-8 w-full" - asChild - disableHoverableContent - /> - - -
- -
-
- - - - + + + {openAskAI !== undefined && ( +
+ + } + onClick={() => { + setHelpMenuOpen(false); + openAskAI(); + }} + /> +
+ )} +
+ +
+
+ + + + + } + /> +
+
+ What's new + {changelogs.map((entry) => ( + + ))} + - } - /> -
-
- What's new - {changelogs.map((entry) => ( - - ))} - -
-
-
- +
+
+
+
+ )} +
); } diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index 409e9b50e5..b2ff4b894f 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -35,7 +35,6 @@ 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; @@ -242,7 +241,6 @@ export function OrganizationSettingsSideMenu({
-
); From 907288a13806efc34d66e5a7298e1df3b51781d6 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 10 Jul 2026 18:06:41 +0100 Subject: [PATCH 25/29] feat(webapp): refine the side menu resize handle and trim the Manage section Slow the resize handle's indigo hover line to 300ms, and make releasing an opening drag keep opening once pulled a tenth of the way out. Also drop the redundant Project settings item from the Manage section (it stays in the project dropdown). --- .../app/components/navigation/SideMenu.tsx | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index f694fba75a..ae6e344f1f 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -196,17 +196,25 @@ const COLLAPSE_ANIM_MS = 200; */ const LABEL_FADE_FRACTION = 0.6; /** - * Where a release in the sub-default zone snaps. Measured from the default width: releasing within - * the first `COLLAPSE_SNAP_THRESHOLD` of the (default → collapsed) range springs back to the - * default width; releasing anywhere past it collapses. At 0.25 the first quarter re-opens and the - * remaining three quarters collapse. Tweak to taste. + * Where a release in the sub-default zone snaps, measured as collapse progress (0 = default + * width, 1 = collapsed): release at progress <= threshold and the menu springs open, past it and + * it collapses. Each drag direction has its own threshold so letting go early usually continues + * the gesture: dragging closed only collapses once past the first quarter of the range, while + * dragging open re-opens once pulled just a tenth of the way out. Tweak to taste. */ const COLLAPSE_SNAP_THRESHOLD = 0.25; +const EXPAND_SNAP_THRESHOLD = 0.9; /** Pointer travel (px) below which a press on the handle counts as a click (toggle), not a drag. */ const DRAG_CLICK_THRESHOLD = 4; /** Left/right padding of the pinned top section + scroll body, interpolated 10px → 4px by --sm-collapse. */ const SIDE_MENU_PAD_X = `calc(0.625rem - 0.375rem * var(--sm-collapse, 0))`; +/** + * Right padding of the scroll body, interpolated 0 → 4px by --sm-collapse: expanded, the reserved + * scrollbar gutter provides the right-side space; collapsed there is no gutter, so this keeps the + * rail buttons inset symmetrically (matching the left padding) instead of touching the edge. + */ +const SIDE_MENU_SCROLL_PAD_RIGHT = `calc(0.25rem * var(--sm-collapse, 0))`; /** Applied to every fading label so it tracks --sm-label-opacity (falls back to fully visible). */ const SIDE_MENU_LABEL_STYLE = { opacity: "var(--sm-label-opacity, 1)" } as const; @@ -492,6 +500,7 @@ export function SideMenu({ const drag = { startX: e.clientX, startWidth: rootRef.current?.getBoundingClientRect().width ?? widthRef.current, + startedCollapsed: isCollapsedRef.current, didDrag: false, }; @@ -530,6 +539,11 @@ export function SideMenu({ } const width = widthRef.current; + // A drag that started collapsed is an opening gesture, so its snap zone is flipped: + // releasing early continues opening rather than falling back to collapsed. + const snapThreshold = drag.startedCollapsed + ? EXPAND_SNAP_THRESHOLD + : COLLAPSE_SNAP_THRESHOLD; if (width >= DEFAULT_WIDTH) { // Rest at the dragged width. const rounded = Math.round(width); @@ -538,7 +552,7 @@ export function SideMenu({ setIsCollapsed(false); persistSideMenuPreferences({ isCollapsed: false, width: rounded }); writeVisual(rounded, 0); - } else if (widthToProgress(width) <= COLLAPSE_SNAP_THRESHOLD) { + } else if (widthToProgress(width) <= snapThreshold) { // Released near the default width — spring back open. expandedWidthRef.current = DEFAULT_WIDTH; isCollapsedRef.current = false; @@ -691,8 +705,8 @@ export function SideMenu({ )} >
-
@@ -1887,8 +1892,7 @@ function ResizeHandle({ >
From aa15a48268541e04663706f1f83d8f32eee47a26 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 10 Jul 2026 19:38:29 +0100 Subject: [PATCH 26/29] feat(webapp): snap side menu button hovers, add Help & Feedback chevron The Org, Project, Environment, Account, and Help & Feedback trigger buttons and the collapsible section headers now snap on hover instead of fading, matching the nav items in the scrollable area. The Project selector keeps a border-color transition so its outline still fades on collapse. Help & Feedback also gains the same up/down disclosure chevron the other menu triggers reveal on hover. --- .../navigation/EnvironmentSelector.tsx | 6 +++--- .../navigation/HelpAndFeedbackPopover.tsx | 20 +++++++++++++++---- .../app/components/navigation/SideMenu.tsx | 14 ++++++------- .../components/navigation/SideMenuSection.tsx | 8 ++++---- 4 files changed, 30 insertions(+), 18 deletions(-) diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index 6af8e963f0..649056b5d1 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -70,7 +70,7 @@ export function EnvironmentSelector({ button={ - + } diff --git a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx index 4390c0fca6..9a4ea7955c 100644 --- a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx +++ b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx @@ -4,6 +4,7 @@ import { Fragment, useState } from "react"; import { AISparkleIcon } from "~/assets/icons/AISparkleIcon"; import { BookIcon } from "~/assets/icons/BookIcon"; import { BulbIcon } from "~/assets/icons/BulbIcon"; +import { DropdownIcon } from "~/assets/icons/DropdownIcon"; import { EnvelopeIcon } from "~/assets/icons/EnvelopeIcon"; import { QuestionMarkIcon } from "~/assets/icons/QuestionMarkIcon"; import { RadarPulseIcon } from "~/assets/icons/RadarPulseIcon"; @@ -62,7 +63,7 @@ export function HelpAndFeedback({ button={ @@ -71,12 +72,12 @@ export function HelpAndFeedback({ {/* Fades via the resizable side menu's `--sm-label-opacity` variable (falls back to fully visible when unset) and truncates as the menu narrows, matching the nav - items. Only max-width and color transition via CSS so the per-frame - variable-driven opacity is not lagged. + items. Only max-width transitions via CSS: the hover color snaps like the nav + items, and transitioning opacity would lag the per-frame variable writes. */} + {/* Up/down chevron revealed on hover, matching the Project menu button. */} + + + } content={ diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index ae6e344f1f..3c772f42a1 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -1156,7 +1156,7 @@ function OrgSelector({ button={ @@ -1178,11 +1178,11 @@ function OrgSelector({ - + } @@ -1325,7 +1325,7 @@ function AccountMenu({ isAdmin, isImpersonating }: { isAdmin: boolean; isImperso setIsOpen(open)} open={isOpen}> + } @@ -1443,7 +1443,7 @@ function ProjectSelector({ button={ - + } diff --git a/apps/webapp/app/components/navigation/SideMenuSection.tsx b/apps/webapp/app/components/navigation/SideMenuSection.tsx index bfcca9a3ca..cd35c30a5c 100644 --- a/apps/webapp/app/components/navigation/SideMenuSection.tsx +++ b/apps/webapp/app/components/navigation/SideMenuSection.tsx @@ -41,18 +41,18 @@ export function SideMenuSection({ {/* Header - fades out as the side menu narrows. Opacity is driven by the resizable SideMenu's `--sm-label-opacity` variable (falls back to 1 when unset) so it scrubs in - real time while dragging. `transition-colors` keeps the hover background animated without - CSS-lagging the var-driven opacity. + real time while dragging. The hover background and text color snap (no transition), to + match the nav items. */}
-
+

{title}

Date: Fri, 10 Jul 2026 20:19:47 +0100 Subject: [PATCH 27/29] fix(webapp): only render the Help & Feedback chevron when expanded The disclosure chevron is a hover affordance with no meaning in the collapsed rail, but it was left mounted there. Rendering it only when the side menu is expanded keeps the collapsed icon clean and stops the chevron slot from affecting the collapsed button's layout or paint. --- .../navigation/HelpAndFeedbackPopover.tsx | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx index 9a4ea7955c..4d2996ea66 100644 --- a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx +++ b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx @@ -85,17 +85,16 @@ export function HelpAndFeedback({ Help & Feedback - {/* Up/down chevron revealed on hover, matching the Project menu button. */} - - - + {/* + Up/down chevron revealed on hover, matching the Project menu button. Only + rendered when expanded so it can never occupy space or paint over the + collapsed icon (it is a hover affordance that has no meaning collapsed). + */} + {!isCollapsed && ( + + + + )} } content={ From 85feda0b740475a046eecb4caa414090f419c9f7 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sat, 11 Jul 2026 07:49:38 +0100 Subject: [PATCH 28/29] fix(webapp): make every side menu item keyboard-focusable in DOM order SimpleTooltip removed its trigger from the tab order (tabIndex=-1), so every side menu item wrapped in one (the nav links and the org/project/environment/account selectors) was skipped when tabbing. Add an opt-in tabbable prop to SimpleTooltip and set it on those triggers, give each the focus-custom ring, and turn the collapsible section headers into real buttons so they are keyboard-operable too. Tab order now follows the DOM top to bottom in both the expanded and collapsed states. --- .../components/navigation/EnvironmentSelector.tsx | 3 ++- .../components/navigation/HelpAndFeedbackPopover.tsx | 1 + apps/webapp/app/components/navigation/SideMenu.tsx | 7 +++++-- .../app/components/navigation/SideMenuItem.tsx | 4 +++- .../app/components/navigation/SideMenuSection.tsx | 12 +++++++++--- apps/webapp/app/components/primitives/Tooltip.tsx | 9 ++++++++- 6 files changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index 649056b5d1..7766d30205 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -70,7 +70,7 @@ export function EnvironmentSelector({ button={ @@ -1192,6 +1192,7 @@ function OrgSelector({ hidden={!isCollapsed} buttonClassName="!h-8" asChild + tabbable disableHoverableContent />
+ {/* Divider - absolutely positioned, fades in as the header fades out (driven by the `--sm-collapse` progress variable, 0 → 1). Only shown while this section is expanded. diff --git a/apps/webapp/app/components/primitives/Tooltip.tsx b/apps/webapp/app/components/primitives/Tooltip.tsx index 92f95babce..7b1121838c 100644 --- a/apps/webapp/app/components/primitives/Tooltip.tsx +++ b/apps/webapp/app/components/primitives/Tooltip.tsx @@ -63,6 +63,7 @@ function SimpleTooltip({ buttonClassName, buttonStyle, asChild = false, + tabbable = false, sideOffset, open, onOpenChange, @@ -78,6 +79,12 @@ function SimpleTooltip({ buttonClassName?: string; buttonStyle?: React.CSSProperties; asChild?: boolean; + /** + * By default the trigger is removed from the tab order (`tabIndex={-1}`) so decorative tooltips + * don't add tab stops. Set this when the trigger wraps a genuinely interactive element (a link + * or button) that should stay keyboard-focusable. + */ + tabbable?: boolean; sideOffset?: number; open?: boolean; onOpenChange?: (open: boolean) => void; @@ -88,7 +95,7 @@ function SimpleTooltip({ Date: Sat, 11 Jul 2026 07:59:30 +0100 Subject: [PATCH 29/29] fix(webapp): inset the focus ring so it is not clipped The shared focus-custom outline sat just outside the border box (offset 0), so it was clipped by any ancestor with overflow hidden/auto (scroll areas, the side menu grid) and covered by adjacent elements' backgrounds. Draw it 1px inside instead so the full ring is always visible. Applies everywhere focus-custom is used. --- apps/webapp/tailwind.config.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/webapp/tailwind.config.js b/apps/webapp/tailwind.config.js index b74ff5bdb5..958649d970 100644 --- a/apps/webapp/tailwind.config.js +++ b/apps/webapp/tailwind.config.js @@ -289,7 +289,10 @@ module.exports = { }, focusStyles: { outline: "1px solid", - outlineOffset: "0px", + // Inset the ring so it is painted inside the element's box. At offset 0 the outline sits + // just outside the border edge and gets clipped by any ancestor with overflow hidden/auto + // (scroll areas, the side menu grid) or covered by an adjacent element's background. + outlineOffset: "-1px", outlineColor: textLink, borderRadius: "3px", },