From 6ff071a487f2c38bc7e0a2281bc7c38f197ceaa6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 17 Oct 2026 22:45:08 +0700 Subject: [PATCH] Keep the compact sidebar close button beside the first nav item (#7244) * fix(app): keep the compact sidebar close button beside the first nav row The close button was absolutely positioned over the nav group, so it covered the first row's label and hover fill. It now shares a flex row with the first item, which shrinks to make room, and uses the compact header's icon button so it gets the same size and round press highlight. * test(app): cover the compact sidebar close button beside the first nav row --- .../app/e2e/browser/sidebar-workspace.spec.ts | 11 +++ packages/app/src/components/left-sidebar.tsx | 91 ++++++++++--------- .../components/sidebar/sidebar-nav-rows.tsx | 62 +++++++++---- 3 files changed, 103 insertions(+), 61 deletions(-) diff --git a/packages/app/e2e/browser/sidebar-workspace.spec.ts b/packages/app/e2e/browser/sidebar-workspace.spec.ts index 8071b3b4f3..85de01a376 100644 --- a/packages/app/e2e/browser/sidebar-workspace.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace.spec.ts @@ -504,6 +504,17 @@ test.describe("Mobile sidebar panelState transition", () => { await expectMobileAgentSidebarHidden(page); }); + test("places the close button beside the first nav row instead of over it", async ({ page }) => { + await gotoAppShell(page); + await openMobileAgentSidebar(page); + await expectMobileAgentSidebarVisible(page); + + const firstRow = await page.getByTestId("sidebar-global-new-workspace").boundingBox(); + const closeButton = await page.getByTestId("sidebar-close").boundingBox(); + if (!firstRow || !closeButton) throw new Error("first nav row and close button must render"); + expect(closeButton.x).toBeGreaterThanOrEqual(firstRow.x + firstRow.width); + }); + test("keeps a pinned workspace rendered while the retained sidebar is closed", async ({ page, }) => { diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 943c1edd27..58d0dee053 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -21,8 +21,14 @@ import { Gesture } from "react-native-gesture-handler"; import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated"; import { scheduleOnRN } from "react-native-worklets"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; +import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; +import { + IconButtonChromeProvider, + iconButtonChromeGlyphSize, + mutedIconColorMapping, +} from "@/components/ui/icon-button-chrome"; import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout"; import { SIDEBAR_RESIZE_ACTIVATION_OFFSET, @@ -62,6 +68,7 @@ import { usePanelStore } from "@/stores/panel-store"; import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window"; import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; +import type { ShortcutKey } from "@/utils/format-shortcut"; import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes"; import { openHostOverview } from "@/navigation/settings-navigation"; import { @@ -74,6 +81,8 @@ import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarCalloutSlot } from "./sidebar-callout-slot"; import { SidebarWorkspaceList } from "./sidebar-workspace-list"; +const ThemedX = withUnistyles(X); + type SidebarTheme = ReturnType["theme"]; const DEV_BUILD_LABEL = process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || null; @@ -545,6 +554,11 @@ function MobileSidebar({ closeSidebar(); }, [closeSidebar]); + const closeButton = useMemo( + () => , + [labels.closeSidebar, closeSidebar], + ); + const mobileSidebarInsetStyle = useMemo( () => ({ paddingTop: insetsTop, @@ -562,30 +576,11 @@ function MobileSidebar({ > - - - - {({ hovered, pressed }) => ( - - )} - - + {isInitialLoad && !hasActiveHostFilter ? ( @@ -626,6 +621,34 @@ function MobileSidebar({ ); } +const NO_SHORTCUT: ShortcutKey[] = []; + +/** The compact header's icon button, so the sidebar's close reads like the header it covers. */ +function SidebarCloseButton({ label, onPress }: { label: string; onPress: () => void }) { + return ( + + + + + + ); +} + function DesktopSidebar({ theme, workspaceGroups, @@ -856,24 +879,8 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, minHeight: 0, }, - mobileCloseButtonRow: { - position: "absolute", - top: theme.spacing[3], - left: 0, - right: 0, - zIndex: 2, - alignItems: "flex-end", - }, mobileCloseButton: { - // The 16px X paints farther inside its 32px hit target than the 14px Settings2 glyph. - // This optical inset puts their painted right edges on the same sidebar rail. - marginRight: theme.spacing[2] + 1.5, - width: 32, - height: 32, - alignItems: "center", - justifyContent: "center", - borderRadius: theme.borderRadius.lg, - backgroundColor: theme.colors.surfaceSidebar, + marginRight: theme.spacing[2], }, desktopSidebarBorder: { borderRightWidth: 1, diff --git a/packages/app/src/components/sidebar/sidebar-nav-rows.tsx b/packages/app/src/components/sidebar/sidebar-nav-rows.tsx index 425280207e..a1481dcff9 100644 --- a/packages/app/src/components/sidebar/sidebar-nav-rows.tsx +++ b/packages/app/src/components/sidebar/sidebar-nav-rows.tsx @@ -1,8 +1,9 @@ import { router, usePathname } from "expo-router"; import { CalendarClock, History, Plus, Search } from "lucide-react-native"; -import { memo, useCallback, useMemo, useRef, type ComponentType } from "react"; +import { memo, useCallback, useMemo, useRef, type ComponentType, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { View, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { PluginSidebarItem } from "@/plugins/sidebar-items"; @@ -30,37 +31,49 @@ interface SidebarNavRowProps { interface SidebarNavRowsProps extends SidebarNavRowProps { /** Style for the group wrapper, which the sidebar owns. */ style?: StyleProp; + /** Sits to the right of the first row, which shrinks to make room. */ + firstRowTrailing?: ReactNode; } /** * Top-level sidebar navigation, ordered and filtered by the user's * `sidebarNavItems` preference. Renders nothing — not even the bordered group - * wrapper — when every item is hidden. + * wrapper — when every item is hidden and there is no `firstRowTrailing`. */ -export function SidebarNavRows({ style, onBeforeNavigate }: SidebarNavRowsProps) { +export function SidebarNavRows({ style, onBeforeNavigate, firstRowTrailing }: SidebarNavRowsProps) { const { items } = useSidebarNavItems("header"); const visibleItems = useMemo(() => items.filter((item) => item.visible), [items]); const groupRef = useRef(null); - if (visibleItems.length === 0) return null; + if (visibleItems.length === 0 && !firstRowTrailing) return null; + + const [firstRow, ...restRows] = visibleItems.map((item) => { + if (item.kind === "plugin") { + return ( + + ); + } + const Row = BUILTIN_ROWS[item.id]; + return ; + }); return ( - {visibleItems.map((item) => { - if (item.kind === "plugin") { - return ( - - ); - } - const Row = BUILTIN_ROWS[item.id]; - return ; - })} + {firstRowTrailing ? ( + + {firstRow} + {firstRowTrailing} + + ) : ( + firstRow + )} + {restRows} ); } @@ -179,3 +192,14 @@ const BUILTIN_ROWS: Record