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,