fix(app): yield app sidebar to narrow settings

This commit is contained in:
Mohamed Boudra
2026-07-14 22:44:03 +02:00
parent 6fc6ff7b7e
commit 1535d70007
6 changed files with 47 additions and 5 deletions
+2
View File
@@ -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.
@@ -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();
});
});
+18 -3
View File
@@ -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 = (
<View style={rowStyle}>
+7
View File
@@ -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;
+2 -2
View File
@@ -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,
@@ -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,