From 1535d7000779958b54761c72faac69e53bab96a4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Jul 2026 15:09:12 +0200 Subject: [PATCH] fix(app): yield app sidebar to narrow settings --- docs/design.md | 2 ++ packages/app/e2e/sidebar-workspace.spec.ts | 9 ++++++++ packages/app/src/app/_layout.tsx | 21 ++++++++++++++++--- packages/app/src/constants/layout.ts | 7 +++++++ packages/app/src/screens/settings-screen.tsx | 4 ++-- .../desktop/scripts/verify-electron-cdp.mjs | 9 ++++++++ 6 files changed, 47 insertions(+), 5 deletions(-) diff --git a/docs/design.md b/docs/design.md index 00c7335e28..e3b5131950 100644 --- a/docs/design.md +++ b/docs/design.md @@ -131,6 +131,8 @@ The branching is one `useIsCompactFormFactor()` check at the top of the screen c The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`) follows a different but parallel rule: tabs collapse on compact, panes split on desktop. The sidebar (`packages/app/src/components/left-sidebar.tsx`) is overlaid on compact and pinned on desktop. +On a narrow desktop settings route, the app navigation yields to Settings' own 320px list + detail split when the remaining width cannot preserve its 400px detail target. That is a topology decision at the app container, not a second compact breakpoint. + Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate. A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR. diff --git a/packages/app/e2e/sidebar-workspace.spec.ts b/packages/app/e2e/sidebar-workspace.spec.ts index c05a0ec4f6..a246968469 100644 --- a/packages/app/e2e/sidebar-workspace.spec.ts +++ b/packages/app/e2e/sidebar-workspace.spec.ts @@ -174,4 +174,13 @@ test.describe("Half-screen desktop layout", () => { await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible(); await expect(page.getByTestId("agent-list-backdrop")).not.toBeVisible(); }); + + test("yields app navigation to the settings split", async ({ page }) => { + await gotoAppShell(page); + await page.getByTestId("sidebar-settings").click(); + + await expect(page.getByTestId("settings-sidebar")).toBeVisible(); + await expect(page.getByTestId("settings-detail-pane")).toBeVisible(); + await expect(page.getByTestId("sidebar-settings")).not.toBeVisible(); + }); }); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 7e17a5e031..5678a1f1ab 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -16,7 +16,7 @@ import { useState, useSyncExternalStore, } from "react"; -import { View } from "react-native"; +import { useWindowDimensions, View } from "react-native"; import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { SafeAreaProvider } from "react-native-safe-area-context"; @@ -37,7 +37,12 @@ import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog"; import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber"; import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal"; import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser"; -import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout"; +import { + getIsElectronRuntime, + SETTINGS_DESKTOP_SPLIT_MIN_WIDTH, + useIsCompactFormFactor, +} from "@/constants/layout"; +import { resolveDesktopSidebarWidth } from "@/components/left-sidebar-width"; import { isNative, isWeb } from "@/constants/platform"; import { HorizontalScrollProvider } from "@/contexts/horizontal-scroll-context"; import { SessionProvider } from "@/contexts/session-context"; @@ -411,6 +416,8 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode); const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); + const sidebarWidth = usePanelStore((state) => state.sidebarWidth); + const { width: viewportWidth } = useWindowDimensions(); const cycleTheme = useCallback(() => { const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName); @@ -463,8 +470,16 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon /> ); + const settingsCanShareAppSidebar = + !pathname.includes("/settings") || + viewportWidth - resolveDesktopSidebarWidth({ requestedWidth: sidebarWidth, viewportWidth }) >= + SETTINGS_DESKTOP_SPLIT_MIN_WIDTH; const desktopSidebarRendered = - !isCompactLayout && chromeEnabled && !isFocusModeEnabled && isDesktopAgentListOpen; + !isCompactLayout && + chromeEnabled && + !isFocusModeEnabled && + isDesktopAgentListOpen && + settingsCanShareAppSidebar; const contentWindowChromeCorners = desktopSidebarRendered ? "top-right" : "both"; const workspaceChrome = ( diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts index cf77d5dc07..676f1bb86d 100644 --- a/packages/app/src/constants/layout.ts +++ b/packages/app/src/constants/layout.ts @@ -15,6 +15,13 @@ export const HEADER_TOP_PADDING_MOBILE = 8; export const MAX_CONTENT_WIDTH = 820; export const COMPACT_FORM_FACTOR_WIDTH = 500; +// Settings uses the canonical desktop list + detail layout. Its sidebar and +// detail target must fit together before it can share width with app navigation. +export const SETTINGS_DESKTOP_SIDEBAR_WIDTH = 320; +export const SETTINGS_DESKTOP_DETAIL_MIN_WIDTH = 400; +export const SETTINGS_DESKTOP_SPLIT_MIN_WIDTH = + SETTINGS_DESKTOP_SIDEBAR_WIDTH + SETTINGS_DESKTOP_DETAIL_MIN_WIDTH; + // Desktop app constants for macOS traffic light buttons // These buttons (close/minimize/maximize) overlay the top-left corner export const DESKTOP_TRAFFIC_LIGHT_WIDTH = 78; diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 5189e5048d..6267a4fa10 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -97,7 +97,7 @@ import { } from "@/screens/settings/host-page"; import ProjectsScreen from "@/screens/projects-screen"; import ProjectSettingsScreen from "@/screens/project-settings-screen"; -import { useIsCompactFormFactor } from "@/constants/layout"; +import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; import { type EnableBuiltInDaemonOption, @@ -1650,7 +1650,7 @@ const desktopStyles = StyleSheet.create((theme) => ({ const sidebarStyles = StyleSheet.create((theme) => ({ desktopContainer: { - width: 320, + width: SETTINGS_DESKTOP_SIDEBAR_WIDTH, borderRightWidth: 1, borderRightColor: theme.colors.border, backgroundColor: theme.colors.surfaceSidebar, diff --git a/packages/desktop/scripts/verify-electron-cdp.mjs b/packages/desktop/scripts/verify-electron-cdp.mjs index 871833f663..d37e13f41c 100644 --- a/packages/desktop/scripts/verify-electron-cdp.mjs +++ b/packages/desktop/scripts/verify-electron-cdp.mjs @@ -90,6 +90,7 @@ async function inspectSettingsGeometry(page) { devicePixelRatio: window.devicePixelRatio, sidebarRect: rect('[data-testid="settings-sidebar"]'), detailPaneRect: rect('[data-testid="settings-detail-pane"]'), + outerAppSidebarSettingsRect: rect('[data-testid="sidebar-settings"]'), backButtonRect: rect('[data-testid="settings-back-to-workspace"]'), detailTitleRect: rect('[data-testid="settings-detail-header-title"]'), detailHeaderLeftRect: headerLeftBounds @@ -423,8 +424,10 @@ async function inspectHalfScreenSettingsLayout(page, platform) { const sidebar = page.getByTestId("settings-sidebar"); const detail = page.getByTestId("settings-detail-pane"); + const outerAppSidebarSettings = page.getByTestId("sidebar-settings"); await sidebar.waitFor({ state: "visible", timeout: 10_000 }); await detail.waitFor({ state: "visible", timeout: 10_000 }); + await outerAppSidebarSettings.waitFor({ state: "hidden", timeout: 10_000 }); const details = await inspectSettingsGeometry(page); const obstruction = getWindowChromeObstruction(platform, details.innerWidth); @@ -450,6 +453,7 @@ async function inspectHalfScreenSettingsLayout(page, platform) { details.sidebarRect.width >= 300 && details.detailPaneRect !== null && details.detailPaneRect.width >= 400 && + details.outerAppSidebarSettingsRect === null && Math.abs(details.sidebarRect.left) <= 1 && sidebarRight !== null && Math.abs(sidebarRight - details.detailPaneRect.left) <= 1 && @@ -732,6 +736,11 @@ async function main() { await collectSettingsSplitResult(page, serverId, desktopStatus, results); + if (outerSidebarDismissed) { + await restoreOuterAppSidebar(page, outerSidebarDismissed); + outerSidebarDismissed = false; + } + const halfScreenDetails = await inspectHalfScreenSettingsLayout( page, desktopDetection.platform,