From c6138e38ae6850b594206925a2197476cd8a781c Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 28 Jun 2026 20:15:52 +0100 Subject: [PATCH 01/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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", }, From 635c9c75910299f76633f3cde81f635923abf02d Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sat, 11 Jul 2026 20:22:08 +0100 Subject: [PATCH 30/41] chore(webapp): use the Tailwind v4 semantic color aliases in the side menu The side menu still used the old charcoal-* utility classes and v3 arbitrary spacing after the v4 merge. Switch to the semantic aliases the rest of the app adopted (background-hover, grid-bright, surface-control) and the numeric spacing scale (pl-1.75, min-w-56, min-w-64). No visual change: the aliases resolve to the same underlying shades. --- .../app/components/navigation/SideMenu.tsx | 36 +++++++++---------- .../components/navigation/SideMenuItem.tsx | 2 +- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 03ea5822be..601ceb2212 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -181,7 +181,7 @@ const IMPERSONATION_ACCENT = { // Keeping these on the root (rather than in React state) means a drag only writes two properties // to one element per frame — no React re-render — so it stays smooth. -/** Collapsed rail width in px (matches the previous `w-[2.75rem]`). */ +/** Collapsed rail width in px (matches the previous `w-11`). */ const COLLAPSED_WIDTH = 44; /** The default/again-expanded width in px (matches the previous `w-56`). */ const DEFAULT_WIDTH = 224; @@ -701,7 +701,7 @@ export function SideMenu({ "min-h-0 overflow-y-auto pt-2.5", isCollapsed ? "scrollbar-none" - : "scrollbar-gutter-stable scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600" + : "scrollbar-gutter-stable scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control" )} >
@@ -1196,7 +1196,7 @@ function OrgSelector({ disableHoverableContent />
-
+
{organizations.length > 1 ? ( ) : ( @@ -1326,7 +1326,7 @@ function AccountMenu({ isAdmin, isImpersonating }: { isAdmin: boolean; isImperso setIsOpen(open)} open={isOpen}> + } @@ -1338,14 +1338,14 @@ function AccountMenu({ isAdmin, isImpersonating }: { isAdmin: boolean; isImperso disableHoverableContent /> {isAdmin && ( -
+
{isImpersonating ? (
-
+
@@ -1488,7 +1488,7 @@ function ProjectSelector({ disableHoverableContent />
-
+
{organization.projects.map((p) => { const isSelected = p.id === project.id; return ( @@ -1579,7 +1579,7 @@ function SwitchOrganizations({ ))}
-
+
Date: Sat, 11 Jul 2026 20:36:38 +0100 Subject: [PATCH 31/41] feat(webapp): move New organization to the top of the org switcher In the Switch organization submenu, New organization now sits above the list of organizations with the divider below it, instead of below the list. --- .../app/components/navigation/SideMenu.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 601ceb2212..8ba92b18f2 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -1601,6 +1601,15 @@ function SwitchOrganizations({ onMouseLeave={handleMouseLeave} >
+ +
+
{organizations.map((org) => ( ))}
-
- -
From fc60f133250bc21f9d85fbcfc4dcc0a906c8cb42 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sat, 11 Jul 2026 22:45:43 +0100 Subject: [PATCH 32/41] feat(webapp): move Manage branches to the top of the branches popover In the dev and preview branch submenus, the Manage branches link now sits above the branch list with the divider below it, instead of below the list. --- .../navigation/EnvironmentSelector.tsx | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index f40f7a819f..85f31bcb16 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -337,6 +337,25 @@ export function BranchesPopoverContent({ return ( <>
+ {parentEnvironment.type === "DEVELOPMENT" ? ( + } + leadingIconClassName="text-text-dimmed" + className={ENV_POPOVER_ITEM_LABEL} + /> + ) : ( + } + leadingIconClassName="text-text-dimmed" + className={ENV_POPOVER_ITEM_LABEL} + /> + )} +
+
{currentBranchIsArchived && ( )}
-
- {parentEnvironment.type === "DEVELOPMENT" ? ( - } - leadingIconClassName="text-text-dimmed" - className={ENV_POPOVER_ITEM_LABEL} - /> - ) : ( - } - leadingIconClassName="text-text-dimmed" - className={ENV_POPOVER_ITEM_LABEL} - /> - )} -
); } From 0c8a1acc4adaa17e0668ab641596ae1d8d77068e Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 12 Jul 2026 11:52:01 +0100 Subject: [PATCH 33/41] fix(webapp): keep collapsed side menu section items out of the tab order When a side menu section is collapsed its items are visually hidden (height 0) but stayed in the DOM, so keyboard and screen-reader users could still focus them. Mark the collapsed content inert so it is removed from both the tab order and the accessibility tree, restoring it when the section expands. --- .../app/components/navigation/SideMenuSection.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/components/navigation/SideMenuSection.tsx b/apps/webapp/app/components/navigation/SideMenuSection.tsx index ff12ea2ae9..1a0a0ad281 100644 --- a/apps/webapp/app/components/navigation/SideMenuSection.tsx +++ b/apps/webapp/app/components/navigation/SideMenuSection.tsx @@ -1,5 +1,5 @@ import { AnimatePresence, motion } from "framer-motion"; -import React, { useCallback, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { ToggleArrowIcon } from "~/assets/icons/ToggleArrowIcon"; type Props = { @@ -27,6 +27,7 @@ export function SideMenuSection({ headerAction, }: Props) { const [isCollapsed, setIsCollapsed] = useState(initialCollapsed); + const contentRef = useRef(null); const handleToggle = useCallback(() => { const newIsCollapsed = !isCollapsed; @@ -34,6 +35,17 @@ export function SideMenuSection({ onCollapseToggle?.(newIsCollapsed); }, [isCollapsed, onCollapseToggle]); + // When the section is collapsed its items are visually hidden (height 0) but stay in the DOM for + // the animation, so remove them from the tab order and the accessibility tree with `inert` — + // otherwise keyboard/screen-reader users can focus invisible items. `inert` doesn't affect + // layout, so the height animation is unchanged. Set the DOM property directly since React 18's + // handling of the `inert` prop is unreliable. + useEffect(() => { + if (contentRef.current) { + contentRef.current.inert = isCollapsed; + } + }, [isCollapsed]); + return (
{/* Header container - stays in DOM to preserve height */} @@ -81,6 +93,7 @@ export function SideMenuSection({
Date: Sun, 12 Jul 2026 12:07:22 +0100 Subject: [PATCH 34/41] chore(webapp): round the focus ring corners one step more Bump the inset focus-custom ring's border-radius from 3px to 5px so the corners read as slightly rounder now that the ring sits inside the element. --- apps/webapp/app/tailwind.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/tailwind.css b/apps/webapp/app/tailwind.css index 4bb73044a8..8dc36163bc 100644 --- a/apps/webapp/app/tailwind.css +++ b/apps/webapp/app/tailwind.css @@ -346,7 +346,7 @@ 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. */ outline-offset: -1px; - border-radius: 3px; + border-radius: 5px; } } From e8a2007781112b0e95b7084c0c3d392cab10ecc8 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 12 Jul 2026 12:18:19 +0100 Subject: [PATCH 35/41] feat(webapp): use a thinner avatar placeholder on the account and team pages Add a 1.5px-stroke variant of the avatar placeholder icon (AvatarCircleIconThin) alongside the 2px default, and a thin prop on UserAvatar/UserProfilePhoto. Use it for the account page's profile picture and the team page member rows; every other avatar keeps the 2px variant. --- .../app/assets/icons/AvatarCircleIcon.tsx | 18 ++++++-- .../app/components/UserProfilePhoto.tsx | 45 ++++++++++++------- .../route.tsx | 1 + .../app/routes/account._index/route.tsx | 2 +- 4 files changed, 46 insertions(+), 20 deletions(-) diff --git a/apps/webapp/app/assets/icons/AvatarCircleIcon.tsx b/apps/webapp/app/assets/icons/AvatarCircleIcon.tsx index 83e67c8468..6d022dc9e4 100644 --- a/apps/webapp/app/assets/icons/AvatarCircleIcon.tsx +++ b/apps/webapp/app/assets/icons/AvatarCircleIcon.tsx @@ -1,4 +1,4 @@ -export function AvatarCircleIcon({ className }: { className?: string }) { +function AvatarCircle({ className, strokeWidth }: { className?: string; strokeWidth: number }) { return ( - - + + ); } + +/** User avatar placeholder with a 2px stroke (the default). */ +export function AvatarCircleIcon({ className }: { className?: string }) { + return ; +} + +/** Thinner 1.5px-stroke variant of {@link AvatarCircleIcon}. */ +export function AvatarCircleIconThin({ className }: { className?: string }) { + return ; +} diff --git a/apps/webapp/app/components/UserProfilePhoto.tsx b/apps/webapp/app/components/UserProfilePhoto.tsx index cd0a2392b3..87f564fdc5 100644 --- a/apps/webapp/app/components/UserProfilePhoto.tsx +++ b/apps/webapp/app/components/UserProfilePhoto.tsx @@ -1,31 +1,46 @@ -import { AvatarCircleIcon } from "~/assets/icons/AvatarCircleIcon"; +import { AvatarCircleIcon, AvatarCircleIconThin } from "~/assets/icons/AvatarCircleIcon"; import { useOptionalUser } from "~/hooks/useUser"; import { cn } from "~/utils/cn"; -export function UserProfilePhoto({ className }: { className?: string }) { +export function UserProfilePhoto({ + className, + thin = false, +}: { + className?: string; + /** Use the thinner 1.5px-stroke placeholder icon (defaults to the 2px variant). */ + thin?: boolean; +}) { const user = useOptionalUser(); - return ; + return ( + + ); } export function UserAvatar({ avatarUrl, name, className, + thin = false, }: { avatarUrl?: string | null; name?: string | null; className?: string; + /** Use the thinner 1.5px-stroke placeholder icon (defaults to the 2px variant). */ + thin?: boolean; }) { - return avatarUrl ? ( -
- {name -
- ) : ( - - ); + if (avatarUrl) { + return ( +
+ {name +
+ ); + } + + const PlaceholderIcon = thin ? AvatarCircleIconThin : AvatarCircleIcon; + return ; } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx index c793554408..fece01bbbc 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx @@ -497,6 +497,7 @@ export default function Page() { avatarUrl={member.user.avatarUrl} name={member.user.name} className="size-10" + thin />
diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index fa24fa31d7..fb426f15b0 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -150,7 +150,7 @@ export default function Page() {
- +
From 654bfc3a20f2cc9d4f04aff6e752ff6601dcac6e Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 12 Jul 2026 12:43:53 +0100 Subject: [PATCH 36/41] feat(webapp): add a 1.25px avatar variant for the team page member rows Add AvatarCircleIconExtraThin (1.25px stroke) and switch the avatar placeholder's thin boolean to a strokeWidth prop (1.25 | 1.5 | 2). The team page member rows use the 1.25px variant at a slightly smaller size; the account profile picture keeps 1.5px; every other avatar stays at 2px. --- .../app/assets/icons/AvatarCircleIcon.tsx | 5 +++ .../app/components/UserProfilePhoto.tsx | 34 ++++++++++++++----- .../route.tsx | 4 +-- .../app/routes/account._index/route.tsx | 2 +- 4 files changed, 33 insertions(+), 12 deletions(-) diff --git a/apps/webapp/app/assets/icons/AvatarCircleIcon.tsx b/apps/webapp/app/assets/icons/AvatarCircleIcon.tsx index 6d022dc9e4..30240b51c9 100644 --- a/apps/webapp/app/assets/icons/AvatarCircleIcon.tsx +++ b/apps/webapp/app/assets/icons/AvatarCircleIcon.tsx @@ -28,3 +28,8 @@ export function AvatarCircleIcon({ className }: { className?: string }) { export function AvatarCircleIconThin({ className }: { className?: string }) { return ; } + +/** Thinnest 1.25px-stroke variant of {@link AvatarCircleIcon}. */ +export function AvatarCircleIconExtraThin({ className }: { className?: string }) { + return ; +} diff --git a/apps/webapp/app/components/UserProfilePhoto.tsx b/apps/webapp/app/components/UserProfilePhoto.tsx index 87f564fdc5..92c435aa10 100644 --- a/apps/webapp/app/components/UserProfilePhoto.tsx +++ b/apps/webapp/app/components/UserProfilePhoto.tsx @@ -1,18 +1,35 @@ -import { AvatarCircleIcon, AvatarCircleIconThin } from "~/assets/icons/AvatarCircleIcon"; +import { + AvatarCircleIcon, + AvatarCircleIconExtraThin, + AvatarCircleIconThin, +} from "~/assets/icons/AvatarCircleIcon"; import { useOptionalUser } from "~/hooks/useUser"; import { cn } from "~/utils/cn"; +/** Stroke width (px) of the placeholder avatar icon shown when there is no photo. */ +type AvatarStrokeWidth = 1.25 | 1.5 | 2; + +const PLACEHOLDER_BY_STROKE_WIDTH = { + 1.25: AvatarCircleIconExtraThin, + 1.5: AvatarCircleIconThin, + 2: AvatarCircleIcon, +} as const; + export function UserProfilePhoto({ className, - thin = false, + strokeWidth = 2, }: { className?: string; - /** Use the thinner 1.5px-stroke placeholder icon (defaults to the 2px variant). */ - thin?: boolean; + strokeWidth?: AvatarStrokeWidth; }) { const user = useOptionalUser(); return ( - + ); } @@ -20,13 +37,12 @@ export function UserAvatar({ avatarUrl, name, className, - thin = false, + strokeWidth = 2, }: { avatarUrl?: string | null; name?: string | null; className?: string; - /** Use the thinner 1.5px-stroke placeholder icon (defaults to the 2px variant). */ - thin?: boolean; + strokeWidth?: AvatarStrokeWidth; }) { if (avatarUrl) { return ( @@ -41,6 +57,6 @@ export function UserAvatar({ ); } - const PlaceholderIcon = thin ? AvatarCircleIconThin : AvatarCircleIcon; + const PlaceholderIcon = PLACEHOLDER_BY_STROKE_WIDTH[strokeWidth]; return ; } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx index fece01bbbc..32920a678f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx @@ -496,8 +496,8 @@ export default function Page() {
diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index fb426f15b0..99ef9b87bb 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -150,7 +150,7 @@ export default function Page() {
- +
From fbc3d88daad0fe288d28288e9235096565b83778 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 12 Jul 2026 12:43:54 +0100 Subject: [PATCH 37/41] fix(webapp): hide the secondary side menu buttons while dragging to resize While the side menu is being drag-resized, the account, dev-connection and collapse buttons (each sitting to the right of a primary item) used to stay put and overlap the org, environment and Help items as the row narrowed. Fade them out during the drag and return them on release. --- .../app/components/navigation/SideMenu.tsx | 39 +++++++++++++++---- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 8ba92b18f2..210dfad242 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -637,7 +637,7 @@ export function SideMenu({ isCollapsed={isCollapsed} />
- +
@@ -664,7 +664,11 @@ export function SideMenu({ className="min-w-0 flex-1" /> {environment.type === "DEVELOPMENT" && project.engine === "V2" && ( - + @@ -1012,6 +1016,7 @@ export function SideMenu({ > {children}
@@ -1762,11 +1774,13 @@ function CollapsibleHeight({ function HelpAndAI({ isCollapsed, + isDragging, organizationId, projectId, onToggleCollapsed, }: { isCollapsed: boolean; + isDragging: boolean; organizationId: string; projectId: string; onToggleCollapsed: () => void; @@ -1785,7 +1799,11 @@ function HelpAndAI({ organizationId={organizationId} projectId={projectId} /> - +
); @@ -1793,15 +1811,22 @@ function HelpAndAI({ function CollapseMenuButton({ isCollapsed, + isDragging = false, onToggle, }: { isCollapsed: boolean; + isDragging?: boolean; onToggle: () => void; }) { const [isHovering, setIsHovering] = useState(false); return ( -
+
From 93d33649f4761c0598fdca9657eff2997e37f728 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 12 Jul 2026 13:17:24 +0100 Subject: [PATCH 38/41] fix(webapp): keep side menu secondary buttons hidden during a fast drag The previous fix faded the account, dev connection and collapse buttons with a class based opacity transition. During a quick drag that 200ms transition lagged behind the shrinking row, so the buttons stayed briefly visible and overlapped the org and Help items (the reported dark overlay). Drive the account and dev connection opacity from the imperative --sm-label-opacity variable instead so it tracks the drag frame by frame with no lag, and hide the collapse button instantly (it cannot use the variable because it stays visible as the expand button when the menu is collapsed). --- .../app/components/navigation/SideMenu.tsx | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 210dfad242..f19046e356 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -1727,9 +1727,10 @@ function CollapsibleElement({ }: { isCollapsed: boolean; /** - * Fade the element out while the menu is being drag-resized. These are the secondary right-hand - * buttons (account, dev connection); hiding them during a drag stops them from overlapping the - * primary item on the left as the row narrows. + * Keep the element non-interactive while the menu is being drag-resized. Its opacity already + * tracks the drag frame-by-frame through the imperative `--sm-label-opacity` variable (below), so + * it fades out in lockstep with the labels with no transition lag; this only stops the fading + * button from swallowing clicks. */ isDragging?: boolean; children: ReactNode; @@ -1738,11 +1739,12 @@ function CollapsibleElement({ return (
{children}
@@ -1821,12 +1823,11 @@ function CollapseMenuButton({ const [isHovering, setIsHovering] = useState(false); return ( -
+ // Hidden instantly (no opacity transition) while dragging so it never lags behind the row as it + // narrows and overlaps the Help & Feedback button. It can't fade with `--sm-label-opacity` like + // the other secondary buttons because it stays visible in the resting collapsed state (where it + // becomes the expand button). +
From e6cfa9500010c206a8e46468973dd1758f9e0348 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 12 Jul 2026 14:03:11 +0100 Subject: [PATCH 39/41] fix(webapp): stop invisible side menu buttons masking the row icons mid-drag Fading the secondary buttons out was not enough: while drag-resizing, the account, dev connection and collapse buttons and the hover chevrons still held their full layout width, so as the row narrowed they pushed the primary item's overflow clip edge into its icon. The org avatar and Help & Feedback icon were progressively swallowed by the clip and read as a dark panel covering them. Drive those widths from the per frame --sm-label-opacity variable instead, in the same curve as the fade, so the space is handed back to the primary item as the menu narrows and its icon stays fully visible at every width. The collapse button only shrinks while dragging since it keeps its natural size as the expand affordance when the menu rests collapsed. --- .../navigation/EnvironmentSelector.tsx | 11 ++- .../navigation/HelpAndFeedbackPopover.tsx | 11 ++- .../app/components/navigation/SideMenu.tsx | 78 +++++++++++-------- 3 files changed, 59 insertions(+), 41 deletions(-) diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index 85f31bcb16..4c9476f9ed 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -106,11 +106,14 @@ export function EnvironmentSelector({ + {/* + The chevron's 16px of width follows the SideMenu drag variable (falls back to full + width elsewhere): an invisible span that holds its width mid-drag pushes the row's + clip edge into the environment icon. + */} diff --git a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx index 16c680d9c6..9a04c48b1c 100644 --- a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx +++ b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx @@ -88,11 +88,16 @@ export function HelpAndFeedback({ {/* 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). + rendered when expanded (it is a hover affordance that has no meaning + collapsed), and its 16px of width follows the SideMenu drag variable so an + invisible chevron can never hold width mid-drag and push the row's clip edge + into the help icon. */} {!isCollapsed && ( - + )} diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index f19046e356..5891a33ff1 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -217,6 +217,15 @@ const SIDE_MENU_PAD_X = `calc(0.625rem - 0.375rem * var(--sm-collapse, 0))`; 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; +/** + * The selector rows' hover chevron: its 16px of layout width follows --sm-label-opacity so an + * invisible chevron can never hold width mid-drag and push the row's overflow clip edge into the + * icon on the left (it would read as the icon being "masked"). Opacity stays class-driven — the + * chevron is a hover-only affordance. + */ +const SIDE_MENU_CHEVRON_STYLE = { + maxWidth: "calc(var(--sm-label-opacity, 1) * 16px)", +} as const; function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); @@ -637,7 +646,7 @@ export function SideMenu({ isCollapsed={isCollapsed} />
- +
@@ -664,11 +673,7 @@ export function SideMenu({ className="min-w-0 flex-1" /> {environment.type === "DEVELOPMENT" && project.engine === "V2" && ( - + @@ -1182,10 +1187,8 @@ function OrgSelector({ @@ -1474,10 +1477,8 @@ function ProjectSelector({ @@ -1720,31 +1721,29 @@ function Integrations({ organization }: { organization: MatchedOrganization }) { * transitions via CSS — transitioning the opacity too would lag the per-frame variable writes). */ function CollapsibleElement({ - isCollapsed, isDragging = false, children, className, }: { - isCollapsed: boolean; - /** - * Keep the element non-interactive while the menu is being drag-resized. Its opacity already - * tracks the drag frame-by-frame through the imperative `--sm-label-opacity` variable (below), so - * it fades out in lockstep with the labels with no transition lag; this only stops the fading - * button from swallowing clicks. - */ + /** Only stops the fading button from swallowing clicks mid-drag; the hiding itself is width+opacity below. */ isDragging?: boolean; children: ReactNode; className?: string; }) { + // Width AND opacity follow the imperative `--sm-label-opacity` variable frame-by-frame. Opacity + // alone is not enough: an invisible button that still holds its 32px of row width pushes the + // primary item's overflow-hidden clip edge into its icon as the row narrows (the icon appears + // "masked" mid-drag). Shrinking the width in the same curve hands that space back to the primary + // item, keeping its icon fully visible at every width. The variable also animates during the + // click-toggle (rAF-driven), so no CSS transition is needed — one would only lag the per-frame + // writes. `isCollapsed` needs no explicit handling: the variable is 0 at rest-collapsed. return (
{children}
@@ -1823,11 +1822,22 @@ function CollapseMenuButton({ const [isHovering, setIsHovering] = useState(false); return ( - // Hidden instantly (no opacity transition) while dragging so it never lags behind the row as it - // narrows and overlaps the Help & Feedback button. It can't fade with `--sm-label-opacity` like - // the other secondary buttons because it stays visible in the resting collapsed state (where it - // becomes the expand button). -
+ // While dragging, width and opacity shrink with the `--sm-label-opacity` variable exactly like + // CollapsibleElement, handing the freed row width to the Help & Feedback item so its icon is + // never pushed into the row's clip edge. Unlike the other secondary buttons this only applies + // mid-drag: at rest the button keeps its natural size in both states (when collapsed it is the + // expand affordance), so the style is dropped on release, where the row relayout hides the snap. +
From 2e3d1fb2eaa61bc894cac1ef7509737ae21513ed Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 12 Jul 2026 14:09:16 +0100 Subject: [PATCH 40/41] feat(webapp): fade the org, project, env and help labels in while dragging the side menu open The nav item labels already reveal in real time during a drag-open, but the org, project, environment and Help & Feedback labels were gated on the collapsed state, which only flips on release, so they stayed hidden for the whole drag and popped in at the end. Drive their width and opacity from the per frame --sm-label-opacity variable instead, exactly like the nav items, and keep those rows in their expanded arrangement while a drag is in progress so the revealing label has the layout it needs in both directions. Outside the resizable side menu the variable is unset and falls back to fully visible, so the environment selector and help popover render unchanged everywhere else. --- .../navigation/EnvironmentSelector.tsx | 40 +++++++------ .../navigation/HelpAndFeedbackPopover.tsx | 19 +++--- .../app/components/navigation/SideMenu.tsx | 58 +++++++++++-------- 3 files changed, 68 insertions(+), 49 deletions(-) diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index 4c9476f9ed..b323615177 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -47,12 +47,15 @@ export function EnvironmentSelector({ environment, className, isCollapsed = false, + isDragging = false, }: { organization: MatchedOrganization; project: SideMenuProject; environment: SideMenuEnvironment; className?: string; isCollapsed?: boolean; + /** True while the side menu is being drag-resized; keeps the row in its expanded arrangement. */ + isDragging?: boolean; }) { const { isManagedCloud } = useFeatures(); const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -80,30 +83,33 @@ export function EnvironmentSelector({ + {/* + Width and opacity are driven by the resizable SideMenu's `--sm-label-opacity` + variable so the label tracks a drag frame-by-frame in both directions (gating on + isCollapsed, which only flips on release, made the label pop in after a drag-open). + Unset in the other places this selector is used (blank-state panels, Limits page) + → falls back to fully visible. + */} - {/* - 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. - */} - - - + {/* diff --git a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx index 9a04c48b1c..3d6045c475 100644 --- a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx +++ b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx @@ -71,17 +71,18 @@ 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 transitions via CSS: the hover color snaps like the nav - items, and transitioning opacity would lag the per-frame variable writes. + Width and opacity follow the resizable side menu's `--sm-label-opacity` variable + (falls back to fully visible when unset) so the label tracks a drag frame-by-frame + in both directions, matching the nav items — gating the width on isCollapsed, + which only flips on release, made the label pop in after a drag-open. No CSS + transitions: they would lag the per-frame variable writes. */} Help & Feedback diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 5891a33ff1..78a1cb90d6 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -215,8 +215,6 @@ const SIDE_MENU_PAD_X = `calc(0.625rem - 0.375rem * var(--sm-collapse, 0))`; * 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; /** * The selector rows' hover chevron: its 16px of layout width follows --sm-label-opacity so an * invisible chevron can never hold width mid-drag and push the row's overflow clip edge into the @@ -226,6 +224,17 @@ const SIDE_MENU_LABEL_STYLE = { opacity: "var(--sm-label-opacity, 1)" } as const const SIDE_MENU_CHEVRON_STYLE = { maxWidth: "calc(var(--sm-label-opacity, 1) * 16px)", } as const; +/** + * The selector rows' label container (org/project/environment): width and opacity both follow + * --sm-label-opacity so the label tracks a drag frame-by-frame in BOTH directions. Gating these on + * `isCollapsed` (which only flips on release) made the labels pop in after a drag-open instead of + * fading in like the nav items. The variable also animates during the click-toggle, and is 0/1 at + * the collapsed/expanded resting states, so no isCollapsed classes or CSS transitions are needed. + */ +const SIDE_MENU_SELECTOR_LABEL_STYLE = { + maxWidth: "calc(var(--sm-label-opacity, 1) * 200px)", + opacity: "var(--sm-label-opacity, 1)", +} as const; function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); @@ -639,11 +648,12 @@ export function SideMenu({ />
-
+
@@ -662,6 +672,7 @@ export function SideMenu({ project={project} environment={environment} isCollapsed={isCollapsed} + isDragging={isDragging} className="w-full" />
@@ -670,6 +681,7 @@ export function SideMenu({ project={project} environment={environment} isCollapsed={isCollapsed} + isDragging={isDragging} className="min-w-0 flex-1" /> {environment.type === "DEVELOPMENT" && project.engine === "V2" && ( @@ -1139,10 +1151,13 @@ function OrgSelector({ organization, organizations, isCollapsed = false, + isDragging = false, }: { organization: MatchedOrganization; organizations: MatchedOrganization[]; isCollapsed?: boolean; + /** True while the menu is being drag-resized; keeps the row in its expanded arrangement. */ + isDragging?: boolean; }) { const currentPlan = useCurrentPlan(); const [isOrgMenuOpen, setOrgMenuOpen] = useState(false); @@ -1167,21 +1182,18 @@ function OrgSelector({ - + {organization.title} @@ -1432,12 +1444,15 @@ function ProjectSelector({ organization, environment, isCollapsed = false, + isDragging = false, className, }: { project: SideMenuProject; organization: MatchedOrganization; environment: SideMenuEnvironment; isCollapsed?: boolean; + /** True while the menu is being drag-resized; keeps the row in its expanded arrangement. */ + isDragging?: boolean; className?: string; }) { const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -1454,24 +1469,21 @@ function ProjectSelector({ - + {project.name ?? "Select a project"} From 49f6968b15045ab0fa7b1ab57064e4f58b7806ad Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 12 Jul 2026 14:30:07 +0100 Subject: [PATCH 41/41] fix(webapp): address side menu review feedback (debounce flush, dev button label, env tooltip) - Flush the side menu preference debounce only on real unmount. The unmount effect listed the fetcher in its deps, and useFetcher returns a fresh object every render, so the cleanup fired on every re-render and prematurely flushed the 500ms debounce. A drag re-renders constantly, so this turned one save into many. Hold the flush routine in a ref and give the effect an empty dep array. - Give the dev server connection button an accessible name so screen readers announce it (it was icon only). - Only show the environment selector tooltip on the collapsed rail, matching the org and project selectors. It had switched to an always-on delayed tooltip, which also leaked into the blank-state panels and Limits page that reuse this selector. --- .../navigation/EnvironmentSelector.tsx | 6 +- .../app/components/navigation/SideMenu.tsx | 78 +++++++++++-------- 2 files changed, 51 insertions(+), 33 deletions(-) diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index b323615177..bc0353537a 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -128,7 +128,11 @@ export function EnvironmentSelector({ content={`${environmentFullTitle(environment)} environment`} side="right" sideOffset={8} - delayDuration={isCollapsed ? 0 : 500} + // Only surface the tooltip on the collapsed rail (instant), matching the Org and Project + // selectors: when expanded the label is already visible, and this selector is also reused + // outside the side menu (blank-state panels, Limits page) where a hover tooltip is unwanted. + hidden={!isCollapsed} + delayDuration={0} buttonClassName="h-8!" asChild tabbable diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 78a1cb90d6..af3cb072ae 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -406,39 +406,46 @@ export function SideMenu({ [user.isImpersonating, preferencesFetcher] ); - // Flush pending preferences on unmount to avoid losing the last toggle - useEffect(() => { - return () => { - if (debounceTimeoutRef.current) { - clearTimeout(debounceTimeoutRef.current); - } - if (user.isImpersonating) return; - const pending = pendingPreferencesRef.current; - const hasPendingChanges = - pending.isCollapsed !== undefined || - pending.width !== undefined || - (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined); + // Always-current flush routine, held in a ref so the unmount effect below can depend on nothing + // and run its cleanup only on a real unmount. `useFetcher` returns a fresh object every render, so + // listing it as an effect dependency would fire the cleanup on every re-render — and a drag + // re-renders constantly — prematurely flushing the debounce each time instead of on unmount. + const flushPendingPreferencesRef = useRef<() => void>(); + flushPendingPreferencesRef.current = () => { + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + if (user.isImpersonating) return; + const pending = pendingPreferencesRef.current; + const hasPendingChanges = + pending.isCollapsed !== undefined || + pending.width !== undefined || + (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined); + if (!hasPendingChanges) return; + + const formData = new FormData(); + 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)); + } + preferencesFetcher.submit(formData, { + method: "POST", + action: "/resources/preferences/sidemenu", + }); + pendingPreferencesRef.current = {}; + }; - if (hasPendingChanges) { - const formData = new FormData(); - 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)); - } - preferencesFetcher.submit(formData, { - method: "POST", - action: "/resources/preferences/sidemenu", - }); - pendingPreferencesRef.current = {}; - } - }; - }, [preferencesFetcher, user.isImpersonating]); + // Flush pending preferences on unmount to avoid losing the last toggle. Empty deps: cleanup must + // run only on a real unmount, not on every re-render (see flushPendingPreferencesRef above). + useEffect(() => { + return () => flushPendingPreferencesRef.current?.(); + }, []); // 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. @@ -695,6 +702,13 @@ export function SideMenu({