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,