From 67628b48fe4c6ec05fbaccca7be4573605c5c5b8 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 04:08:50 +0000 Subject: [PATCH] Keep workspaces usable when mobile navigation overflows (#8727) * fix(app): cap the compact sidebar nav block so plugins cant crowd out workspaces The compact sidebar's top nav group (builtin rows plus plugin header items) was a fixed block with no height bound. On short screens - a foldable's cover display, for instance - enough plugin items pushed the scrollable workspace list to zero height, leaving it unusable. Wrap the nav rows in a bounded ScrollView capped at half the sidebar height. Rows scroll inside the cap when they overflow; the separator moves to the scroll frame so it stays fixed at the region's bottom. On native the scroll view is the gesture-handler variant with the close pan as a simultaneous handler, matching the workspace list's contract. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(app): keep the mobile nav separator theme-safe and disable iOS bounce Review follow-ups: the border lived on the gesture-handler ScrollView, which Unistyles never rewrites, so move the divider to a SidebarSeparator sibling. On iOS the block rubber-banded under a fixed divider even when every row fit, so disable alwaysBounceVertical. Inline simultaneousHandlers now that GHScrollView types it, and tag the region for Maestro. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(app): keep workspaces reachable with overflowing sidebar navigation Reproduce the short compact sidebar failure with the existing ten-row plugin fixture. Check that navigation scrolls independently and both navigation actions and workspaces remain usable. Co-authored-by: hugo --------- Co-authored-by: hugo Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../e2e/browser/plugin-sidebar-items.spec.ts | 32 ++++++++++++ packages/app/src/components/left-sidebar.tsx | 52 ++++++++++++++++--- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/packages/app/e2e/browser/plugin-sidebar-items.spec.ts b/packages/app/e2e/browser/plugin-sidebar-items.spec.ts index 760eae9f79..dfba0ec816 100644 --- a/packages/app/e2e/browser/plugin-sidebar-items.spec.ts +++ b/packages/app/e2e/browser/plugin-sidebar-items.spec.ts @@ -2,6 +2,7 @@ import path from "node:path"; import type { Locator, Page } from "@playwright/test"; import { expect, test } from "../support/fixtures"; import { gotoWorkspace } from "../support/helpers/launcher"; +import { gotoAppShell } from "../support/helpers/app"; import { BOTS_PLUGIN_ID, COMPACT, @@ -176,6 +177,37 @@ test.describe("Plugin sidebar items", () => { await cleanup(); }); + test("a short compact sidebar keeps workspaces usable while navigation scrolls", async ({ + page, + }) => { + await page.setViewportSize({ width: 390, height: 600 }); + await gotoAppShell(page); + await page.getByRole("button", { name: "Open menu", exact: true }).first().click(); + await expect(botRow(page, "status")).toBeAttached(); + const workspace = visibleTestId(page, `sidebar-workspace-row-${getServerId()}:${workspaceId}`); + await expect(workspace).toBeInViewport({ ratio: 1 }); + + await test.step("scrolling navigation reaches its last row without moving workspaces", async () => { + const before = await boxOf(workspace); + await visibleTestId(page, "sidebar-global-new-workspace").hover(); + await page.mouse.wheel(0, 800); + await expect(botRow(page, "status")).toBeInViewport({ ratio: 1 }); + await expect(workspace).toBeInViewport({ ratio: 1 }); + expect((await boxOf(workspace)).y).toBe(before.y); + await botRow(page, "status").click(); + await expect(page.getByText("Bot status details", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Close bot status", exact: true }).click(); + }); + + await test.step("the workspace still opens and closes the sidebar", async () => { + await workspace.click(); + await expect( + page.getByRole("button", { name: "Open menu", exact: true }).first(), + ).toBeVisible(); + await expect(workspace).not.toBeInViewport(); + }); + }); + test("a restored plugin screen waits for its catalog before rendering", async ({ page }) => { await page.setViewportSize(WIDE); await gotoWorkspace(page, workspaceId); diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index fff2e84f45..7f59312f7a 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -12,12 +12,13 @@ import { useTranslation } from "react-i18next"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { Pressable, + ScrollView, StyleSheet as RNStyleSheet, Text, useWindowDimensions, View, } from "react-native"; -import { Gesture } from "react-native-gesture-handler"; +import { Gesture, ScrollView as GHScrollView } 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"; @@ -41,6 +42,7 @@ import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-heade import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; +import { isNative as platformIsNative } from "@/constants/platform"; import { useOpenAddProject } from "@/hooks/use-open-add-project"; import { useImportSession } from "@/hooks/use-import-session"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; @@ -555,6 +557,14 @@ function MobileSidebar({ [insetsTop, insetsBottom, theme.colors.surfaceSidebar], ); + const navRows = ( + + ); + return ( - + {platformIsNative ? ( + + {navRows} + + ) : ( + + {navRows} + + )} + {isInitialLoad && !hasActiveHostFilter ? ( @@ -839,6 +867,18 @@ const styles = StyleSheet.create((theme) => ({ sidebarHeaderGroupBelowChrome: { paddingTop: 0, }, + // The nav block caps at half the sidebar; a long item list scrolls inside the + // cap instead of crowding the workspace list off a short screen. + mobileNavScroll: { + flexGrow: 0, + flexShrink: 1, + maxHeight: "50%", + }, + mobileNavRowsGroup: { + paddingTop: theme.spacing[2], + gap: 2, + paddingBottom: theme.spacing[1.5], + }, sidebarContent: { flex: 1, minHeight: 0,