diff --git a/docs/design.md b/docs/design.md index e3b5131950..588b539044 100644 --- a/docs/design.md +++ b/docs/design.md @@ -131,7 +131,7 @@ 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. +On a narrow desktop route, app navigation yields to the rendered content topology when the remaining width cannot preserve its center target: Settings keeps its 320px list + 400px detail split, and a workspace Explorer keeps its current visible width plus a 400px center pane. That is a topology decision at the app container, not a second compact breakpoint. Temporary width clamps are render-only; widening restores the user's saved sidebar widths. 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. diff --git a/packages/app/e2e/sidebar-workspace.spec.ts b/packages/app/e2e/sidebar-workspace.spec.ts index a246968469..0a7ec1a7f9 100644 --- a/packages/app/e2e/sidebar-workspace.spec.ts +++ b/packages/app/e2e/sidebar-workspace.spec.ts @@ -183,4 +183,28 @@ test.describe("Half-screen desktop layout", () => { await expect(page.getByTestId("settings-detail-pane")).toBeVisible(); await expect(page.getByTestId("sidebar-settings")).not.toBeVisible(); }); + + test("yields app navigation to the Explorer", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "sidebar-half-screen-explorer-" }); + + try { + await gotoAppShell(page); + await waitForSidebarProject(page, path.basename(workspace.repoPath)); + await openWorkspaceFromSidebar(page, workspace.workspaceId); + + await page.getByTestId("workspace-explorer-toggle").first().click(); + await expect( + page.getByTestId("explorer-tab-files").filter({ visible: true }).first(), + ).toBeVisible(); + await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible(); + await expect + .poll( + async () => + (await page.getByTestId("workspace-tabs-row").first().boundingBox())?.width ?? 0, + ) + .toBeGreaterThanOrEqual(400); + } finally { + await workspace.cleanup(); + } + }); }); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 5678a1f1ab..1e50d3fb36 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -37,12 +37,11 @@ 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"; + canDesktopAppSidebarShare, + resolveDesktopAppContentMinimum, +} from "@/components/desktop-sidebar-layout"; import { isNative, isWeb } from "@/constants/platform"; import { HorizontalScrollProvider } from "@/contexts/horizontal-scroll-context"; import { SessionProvider } from "@/contexts/session-context"; @@ -416,7 +415,9 @@ 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 isDesktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); const sidebarWidth = usePanelStore((state) => state.sidebarWidth); + const explorerWidth = usePanelStore((state) => state.explorerWidth); const { width: viewportWidth } = useWindowDimensions(); const cycleTheme = useCallback(() => { @@ -470,16 +471,22 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon /> ); - const settingsCanShareAppSidebar = - !pathname.includes("/settings") || - viewportWidth - resolveDesktopSidebarWidth({ requestedWidth: sidebarWidth, viewportWidth }) >= - SETTINGS_DESKTOP_SPLIT_MIN_WIDTH; + const appContentMinimumWidth = resolveDesktopAppContentMinimum({ + isSettingsRoute: pathname.includes("/settings"), + isWorkspaceExplorerOpen: pathname.includes("/workspace/") && isDesktopFileExplorerOpen, + requestedExplorerWidth: explorerWidth, + viewportWidth, + }); const desktopSidebarRendered = !isCompactLayout && chromeEnabled && !isFocusModeEnabled && isDesktopAgentListOpen && - settingsCanShareAppSidebar; + canDesktopAppSidebarShare({ + contentMinimumWidth: appContentMinimumWidth, + requestedSidebarWidth: sidebarWidth, + viewportWidth, + }); const contentWindowChromeCorners = desktopSidebarRendered ? "top-right" : "both"; const workspaceChrome = ( diff --git a/packages/app/src/components/desktop-sidebar-layout.test.ts b/packages/app/src/components/desktop-sidebar-layout.test.ts new file mode 100644 index 0000000000..ca9d8a97fa --- /dev/null +++ b/packages/app/src/components/desktop-sidebar-layout.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + canDesktopAppSidebarShare, + resolveDesktopAppContentMinimum, + resolveDesktopExplorerWidth, + resolveDesktopSidebarWidth, +} from "@/components/desktop-sidebar-layout"; + +describe("desktop sidebar layout", () => { + it("clamps a persisted wide sidebar to preserve the center pane", () => { + const atHalfScreen = resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 751 }); + expect(atHalfScreen).toBe(351); + expect(751 - atHalfScreen).toBe(400); + + const atBreakpoint = resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 720 }); + expect(atBreakpoint).toBe(320); + expect(720 - atBreakpoint).toBe(400); + + expect(resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 1440 })).toBe(600); + }); + + it("keeps a temporarily narrow explorer render-only", () => { + expect(resolveDesktopExplorerWidth({ requestedWidth: 400, viewportWidth: 751 })).toBe(351); + expect(resolveDesktopExplorerWidth({ requestedWidth: 400, viewportWidth: 1440 })).toBe(400); + }); + + it("yields app navigation when settings or Explorer need the shell width", () => { + const settingsMinimum = resolveDesktopAppContentMinimum({ + isSettingsRoute: true, + isWorkspaceExplorerOpen: false, + requestedExplorerWidth: 400, + viewportWidth: 751, + }); + expect(settingsMinimum).toBe(720); + expect( + canDesktopAppSidebarShare({ + contentMinimumWidth: settingsMinimum, + requestedSidebarWidth: 320, + viewportWidth: 751, + }), + ).toBe(false); + + const explorerMinimum = resolveDesktopAppContentMinimum({ + isSettingsRoute: false, + isWorkspaceExplorerOpen: true, + requestedExplorerWidth: 400, + viewportWidth: 751, + }); + expect(explorerMinimum).toBe(751); + expect( + canDesktopAppSidebarShare({ + contentMinimumWidth: explorerMinimum, + requestedSidebarWidth: 320, + viewportWidth: 751, + }), + ).toBe(false); + expect( + canDesktopAppSidebarShare({ + contentMinimumWidth: resolveDesktopAppContentMinimum({ + isSettingsRoute: false, + isWorkspaceExplorerOpen: true, + requestedExplorerWidth: 400, + viewportWidth: 1120, + }), + requestedSidebarWidth: 320, + viewportWidth: 1120, + }), + ).toBe(true); + }); +}); diff --git a/packages/app/src/components/desktop-sidebar-layout.ts b/packages/app/src/components/desktop-sidebar-layout.ts new file mode 100644 index 0000000000..cb3a07da9a --- /dev/null +++ b/packages/app/src/components/desktop-sidebar-layout.ts @@ -0,0 +1,76 @@ +import { SETTINGS_DESKTOP_SPLIT_MIN_WIDTH } from "@/constants/layout"; +import { + MAX_EXPLORER_SIDEBAR_WIDTH, + MAX_SIDEBAR_WIDTH, + MIN_EXPLORER_SIDEBAR_WIDTH, + MIN_SIDEBAR_WIDTH, +} from "@/stores/panel-store"; + +export const MIN_DESKTOP_CENTER_WIDTH = 400; + +function resolveDesktopPanelWidth(input: { + requestedWidth: number; + viewportWidth: number; + minimumWidth: number; + maximumWidth: number; +}): number { + "worklet"; + const maximumVisibleWidth = Math.max( + input.minimumWidth, + Math.min(input.maximumWidth, input.viewportWidth - MIN_DESKTOP_CENTER_WIDTH), + ); + return Math.max(input.minimumWidth, Math.min(maximumVisibleWidth, input.requestedWidth)); +} + +export function resolveDesktopSidebarWidth(input: { + requestedWidth: number; + viewportWidth: number; +}): number { + return resolveDesktopPanelWidth({ + ...input, + minimumWidth: MIN_SIDEBAR_WIDTH, + maximumWidth: MAX_SIDEBAR_WIDTH, + }); +} + +export function resolveDesktopExplorerWidth(input: { + requestedWidth: number; + viewportWidth: number; +}): number { + return resolveDesktopPanelWidth({ + ...input, + minimumWidth: MIN_EXPLORER_SIDEBAR_WIDTH, + maximumWidth: MAX_EXPLORER_SIDEBAR_WIDTH, + }); +} + +export function resolveDesktopAppContentMinimum(input: { + isSettingsRoute: boolean; + isWorkspaceExplorerOpen: boolean; + requestedExplorerWidth: number; + viewportWidth: number; +}): number { + const workspaceMinimum = input.isWorkspaceExplorerOpen + ? MIN_DESKTOP_CENTER_WIDTH + + resolveDesktopExplorerWidth({ + requestedWidth: input.requestedExplorerWidth, + viewportWidth: input.viewportWidth, + }) + : 0; + return Math.max(input.isSettingsRoute ? SETTINGS_DESKTOP_SPLIT_MIN_WIDTH : 0, workspaceMinimum); +} + +export function canDesktopAppSidebarShare(input: { + contentMinimumWidth: number; + requestedSidebarWidth: number; + viewportWidth: number; +}): boolean { + return ( + input.viewportWidth - + resolveDesktopSidebarWidth({ + requestedWidth: input.requestedSidebarWidth, + viewportWidth: input.viewportWidth, + }) >= + input.contentMinimumWidth + ); +} diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 90a4026e2f..c691332e0d 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -22,13 +22,7 @@ import { } from "@/git/pull-request-panel"; import { useCheckoutGitActionsStore } from "@/git/actions-store"; import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data"; -import { - usePanelStore, - selectIsFileExplorerOpen, - MIN_EXPLORER_SIDEBAR_WIDTH, - MAX_EXPLORER_SIDEBAR_WIDTH, - type ExplorerTab, -} from "@/stores/panel-store"; +import { usePanelStore, selectIsFileExplorerOpen, type ExplorerTab } from "@/stores/panel-store"; import { useToast } from "@/contexts/toast-context"; import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; @@ -41,8 +35,8 @@ import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { RetainedPanelActivity } from "@/components/retained-panel"; import { isWeb } from "@/constants/platform"; import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; +import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout"; -const MIN_CHAT_WIDTH = 400; function logExplorerSidebar(_event: string, _details: Record): void {} interface ExplorerSidebarProps { @@ -163,18 +157,16 @@ export function ExplorerSidebar({ isGit, }); const { width: viewportWidth } = useWindowDimensions(); - const startWidthRef = useRef(explorerWidth); - const resizeWidth = useSharedValue(explorerWidth); + const visibleExplorerWidth = resolveDesktopExplorerWidth({ + requestedWidth: explorerWidth, + viewportWidth, + }); + const startWidthRef = useRef(visibleExplorerWidth); + const resizeWidth = useSharedValue(visibleExplorerWidth); useEffect(() => { - const maxWidth = Math.max( - MIN_EXPLORER_SIDEBAR_WIDTH, - Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH), - ); - if (explorerWidth > maxWidth) { - setExplorerWidth(maxWidth); - } - }, [explorerWidth, setExplorerWidth, viewportWidth]); + resizeWidth.value = visibleExplorerWidth; + }, [resizeWidth, visibleExplorerWidth]); const handleDesktopClose = useCallback(() => { logExplorerSidebar("handleClose", { @@ -190,22 +182,20 @@ export function ExplorerSidebar({ .enabled(true) .hitSlop({ left: 8, right: 8, top: 0, bottom: 0 }) .onStart(() => { - startWidthRef.current = explorerWidth; - resizeWidth.value = explorerWidth; + startWidthRef.current = visibleExplorerWidth; + resizeWidth.value = visibleExplorerWidth; }) .onUpdate((event) => { const newWidth = startWidthRef.current - event.translationX; - const maxWidth = Math.max( - MIN_EXPLORER_SIDEBAR_WIDTH, - Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH), - ); - const clampedWidth = Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth)); - resizeWidth.value = clampedWidth; + resizeWidth.value = resolveDesktopExplorerWidth({ + requestedWidth: newWidth, + viewportWidth, + }); }) .onEnd(() => { runOnJS(setExplorerWidth)(resizeWidth.value); }), - [explorerWidth, resizeWidth, setExplorerWidth, viewportWidth], + [resizeWidth, setExplorerWidth, viewportWidth, visibleExplorerWidth], ); const resizeAnimatedStyle = useAnimatedStyle(() => ({ diff --git a/packages/app/src/components/left-sidebar-width.test.ts b/packages/app/src/components/left-sidebar-width.test.ts deleted file mode 100644 index f99e3bd77a..0000000000 --- a/packages/app/src/components/left-sidebar-width.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveDesktopSidebarWidth } from "@/components/left-sidebar-width"; - -describe("desktop sidebar width", () => { - it("clamps a persisted wide sidebar to preserve 400px of content", () => { - const atHalfScreen = resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 751 }); - expect(atHalfScreen).toBe(351); - expect(751 - atHalfScreen).toBe(400); - - const atBreakpoint = resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 720 }); - expect(atBreakpoint).toBe(320); - expect(720 - atBreakpoint).toBe(400); - - expect(resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 1440 })).toBe(600); - }); -}); diff --git a/packages/app/src/components/left-sidebar-width.ts b/packages/app/src/components/left-sidebar-width.ts deleted file mode 100644 index 7d2af07065..0000000000 --- a/packages/app/src/components/left-sidebar-width.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store"; - -const MIN_CHAT_WIDTH = 400; - -export function resolveDesktopSidebarWidth(input: { - requestedWidth: number; - viewportWidth: number; -}): number { - "worklet"; - const maxWidth = Math.max( - MIN_SIDEBAR_WIDTH, - Math.min(MAX_SIDEBAR_WIDTH, input.viewportWidth - MIN_CHAT_WIDTH), - ); - return Math.max(MIN_SIDEBAR_WIDTH, Math.min(maxWidth, input.requestedWidth)); -} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index efcf1988aa..25b957ed89 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -25,7 +25,7 @@ import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-nativ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; -import { resolveDesktopSidebarWidth } from "@/components/left-sidebar-width"; +import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout"; import { HostPicker } from "@/components/hosts/host-picker"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/sidebar-display-preferences-menu";