From 50ed0d0ab11d8f5d9543096329436bb37c303ff0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Jul 2026 23:01:14 +0200 Subject: [PATCH] Fix desktop layout and window controls at half-screen width (#1983) * fix(app): preserve desktop layout in half-screen windows Treat native window controls as physical corner obstructions instead of deriving clearance from compact layout state. Keep 751px windows in desktop mode while preserving usable content when persisted sidebars are wide. * fix(app): retain sidebar width across window resizes * fix(app): yield app sidebar to narrow settings * test(app): wait for mobile sidebar motion to settle * fix(app): yield navigation to narrow workspace explorer * fix(app): mark sidebar sizing helpers as worklets * fix(app): hide app navigation when space is constrained --- docs/design.md | 4 + docs/development.md | 11 + packages/app/e2e/helpers/sidebar.ts | 9 +- packages/app/e2e/sidebar-workspace.spec.ts | 43 ++ packages/app/src/app/_layout.tsx | 62 +- .../src/components/attachment-lightbox.tsx | 88 +-- .../components/desktop-sidebar-layout.test.ts | 70 +++ .../src/components/desktop-sidebar-layout.ts | 78 +++ .../app/src/components/explorer-sidebar.tsx | 62 +- .../src/components/headers/screen-header.tsx | 27 +- packages/app/src/components/left-sidebar.tsx | 91 +-- .../components/split-container-focus.test.ts | 37 ++ .../src/components/split-container-focus.ts | 21 + .../app/src/components/split-container.tsx | 119 ++-- packages/app/src/constants/layout.ts | 7 + packages/app/src/desktop/host.ts | 1 + .../app/src/mobile-panels/presentation.tsx | 3 +- packages/app/src/screens/settings-screen.tsx | 80 ++- .../screens/workspace/workspace-screen.tsx | 71 ++- packages/app/src/styles/unistyles.ts | 2 +- packages/app/src/utils/desktop-window.test.ts | 129 ++--- packages/app/src/utils/desktop-window.ts | 187 ------ packages/app/src/utils/desktop-window.tsx | 238 ++++++++ packages/desktop/package.json | 1 + .../desktop/scripts/verify-electron-cdp.mjs | 533 ++++++++++++------ packages/desktop/src/preload.ts | 2 + packages/desktop/src/window/window-manager.ts | 5 + 27 files changed, 1263 insertions(+), 718 deletions(-) create mode 100644 packages/app/src/components/desktop-sidebar-layout.test.ts create mode 100644 packages/app/src/components/desktop-sidebar-layout.ts create mode 100644 packages/app/src/components/split-container-focus.test.ts create mode 100644 packages/app/src/components/split-container-focus.ts delete mode 100644 packages/app/src/utils/desktop-window.ts create mode 100644 packages/app/src/utils/desktop-window.tsx diff --git a/docs/design.md b/docs/design.md index 172416984..588b53904 100644 --- a/docs/design.md +++ b/docs/design.md @@ -131,6 +131,10 @@ 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 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. + 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/docs/development.md b/docs/development.md index d435f11f0..b86772680 100644 --- a/docs/development.md +++ b/docs/development.md @@ -72,6 +72,17 @@ Starting the service must not create, focus, reveal, or leave behind macOS Simul It launches its own Electron-flavored Expo server and passes that URL to Electron. Override the CDP port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy. +With desktop dev running, verify the real BrowserWindow, titlebar clearance, fullscreen +transition, and 751-pixel settings split with: + +```bash +npm run verify:electron-cdp --workspace=@getpaseo/desktop +``` + +The verifier reads the same `EXPO_PORT` and +`PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` environment names as desktop dev. Set both when +testing an isolated instance on non-default ports. + When running a dedicated Electron QA instance against a non-default Expo port, set `EXPO_DEV_URL` explicitly. Desktop main defaults to `http://localhost:8081`, so `PASEO_PORT=57928` alone starts Metro on 57928 but Electron still loads 8081. diff --git a/packages/app/e2e/helpers/sidebar.ts b/packages/app/e2e/helpers/sidebar.ts index 63a6e0cec..c6d0fb3ce 100644 --- a/packages/app/e2e/helpers/sidebar.ts +++ b/packages/app/e2e/helpers/sidebar.ts @@ -61,13 +61,14 @@ export async function openMobileAgentSidebar(page: Page): Promise { export async function closeMobileAgentSidebar(page: Page): Promise { const closeButton = page.getByTestId("sidebar-close"); - await expect(closeButton).toBeInViewport({ timeout: 5_000 }); - await closeButton.click({ force: true }); + await expect(closeButton).toBeInViewport({ ratio: 1, timeout: 5_000 }); + await closeButton.click(); } -// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position. +// The mobile sidebar panel animates via translateX. Waiting for its header to be fully visible +// prevents a close click from targeting a button while the panel is still moving. export async function expectMobileAgentSidebarVisible(page: Page): Promise { - await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 }); + await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ ratio: 1, timeout: 5_000 }); } export async function expectMobileAgentSidebarHidden(page: Page): Promise { diff --git a/packages/app/e2e/sidebar-workspace.spec.ts b/packages/app/e2e/sidebar-workspace.spec.ts index ed6940203..0a7ec1a7f 100644 --- a/packages/app/e2e/sidebar-workspace.spec.ts +++ b/packages/app/e2e/sidebar-workspace.spec.ts @@ -165,3 +165,46 @@ test.describe("Mobile sidebar panelState transition", () => { await expectMobileAgentSidebarHidden(page); }); }); + +test.describe("Half-screen desktop layout", () => { + test.use({ viewport: { width: 751, height: 982 } }); + + test("keeps the pinned sidebar at half of a 14-inch Mac display", async ({ page }) => { + await gotoAppShell(page); + 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(); + }); + + 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 1c0449fd3..a61a603fa 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"; @@ -38,6 +38,10 @@ import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-short import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal"; import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser"; import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout"; +import { + 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"; @@ -91,6 +95,7 @@ import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme"; import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles"; import type { HostProfile } from "@/types/host-connection"; import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle"; +import { WindowChromeProvider, WindowChromeRegion } from "@/utils/desktop-window"; import { buildOpenProjectRoute, parseServerIdFromPathname } from "@/utils/host-routes"; import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing"; import { navigateToAgent } from "@/utils/navigate-to-agent"; @@ -409,6 +414,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer); 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(() => { const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName); @@ -454,22 +464,46 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon useActiveWorktreeNewAction(); useGlobalNewWorkspaceAction(); + const appContentMinimumWidth = resolveDesktopAppContentMinimum({ + isSettingsRoute: pathname.includes("/settings"), + isWorkspaceExplorerOpen: pathname.includes("/workspace/") && isDesktopFileExplorerOpen, + requestedExplorerWidth: explorerWidth, + viewportWidth, + }); + const desktopSidebarRendered = + !isCompactLayout && + chromeEnabled && + !isFocusModeEnabled && + isDesktopAgentListOpen && + canDesktopAppSidebarShare({ + contentMinimumWidth: appContentMinimumWidth, + requestedSidebarWidth: sidebarWidth, + viewportWidth, + }); + const contentWindowChromeCorners = desktopSidebarRendered ? "top-right" : "both"; const sidebarChrome = ( ); - const workspaceChrome = ( - {!isCompactLayout ? sidebarChrome : null} + {!isCompactLayout ? ( + + {sidebarChrome} + + ) : null} {isCompactLayout && chromeEnabled ? ( - {children} + + {children} + ) : ( - {children} + + {children} + )} ); @@ -863,13 +897,15 @@ function RuntimeProviders({ children }: { children: ReactNode }) { function RootProviders({ children }: { children: ReactNode }) { return ( - - - - {children} - - - + + + + + {children} + + + + ); } diff --git a/packages/app/src/components/attachment-lightbox.tsx b/packages/app/src/components/attachment-lightbox.tsx index b44e76928..473278229 100644 --- a/packages/app/src/components/attachment-lightbox.tsx +++ b/packages/app/src/components/attachment-lightbox.tsx @@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next"; import type { AttachmentMetadata } from "@/attachments/types"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { isWeb } from "@/constants/platform"; +import { WindowChromeRootRegion, WindowChromeSafeArea } from "@/utils/desktop-window"; interface AttachmentLightboxProps { metadata: AttachmentMetadata | null; @@ -38,15 +39,18 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp }; }, [metadata, onClose]); - const closeButtonStyle = useMemo( + const closeButtonRowStyle = useMemo( () => [ - styles.closeButton, + styles.closeButtonRow, { top: insets.top + theme.spacing[3], - right: insets.right + theme.spacing[3], }, ], - [insets.top, insets.right, theme.spacing], + [insets.top, theme.spacing], + ); + const closeButtonStyle = useMemo( + () => [styles.closeButton, { marginRight: insets.right + theme.spacing[3] }], + [insets.right, theme.spacing], ); const handleImageError = useCallback(() => setErrored(true), []); @@ -61,42 +65,46 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp return ( - - - - - {hasError ? ( - {t("message.attachments.imageLoadFailed")} - ) : ( - - - - )} - + + - - + style={styles.backdrop} + /> + + + {hasError ? ( + {t("message.attachments.imageLoadFailed")} + ) : ( + + + + )} + + + + + + + - + ); } @@ -129,6 +137,13 @@ const styles = StyleSheet.create((theme) => ({ bottom: 0, pointerEvents: "box-none", }, + closeButtonRow: { + position: "absolute", + left: 0, + right: 0, + alignItems: "flex-end", + pointerEvents: "box-none", + }, imageArea: { flex: 1, alignItems: "center", @@ -148,7 +163,6 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, }, closeButton: { - position: "absolute", width: 32, height: 32, borderRadius: 16, 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 000000000..ca9d8a97f --- /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 000000000..e71b42273 --- /dev/null +++ b/packages/app/src/components/desktop-sidebar-layout.ts @@ -0,0 +1,78 @@ +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 { + "worklet"; + return resolveDesktopPanelWidth({ + ...input, + minimumWidth: MIN_SIDEBAR_WIDTH, + maximumWidth: MAX_SIDEBAR_WIDTH, + }); +} + +export function resolveDesktopExplorerWidth(input: { + requestedWidth: number; + viewportWidth: number; +}): number { + "worklet"; + 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 1d964dc7a..c691332e0 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"; @@ -36,13 +30,13 @@ import { HEADER_INNER_HEIGHT } from "@/constants/layout"; import { GitDiffPane } from "@/git/diff-pane"; import { FileExplorerPane } from "./file-explorer-pane"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; -import { useWindowControlsPadding } from "@/utils/desktop-window"; +import { WindowChromeSafeArea } from "@/utils/desktop-window"; 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(() => ({ @@ -300,7 +290,6 @@ function ExplorerSidebarContent({ const { theme } = useUnistyles(); const { t } = useTranslation(); const toast = useToast(); - const padding = useWindowControlsPadding("explorerSidebar"); const canQueryPullRequest = isGit && Boolean(workspaceRoot); const prPane = usePrPaneData({ serverId, @@ -325,15 +314,15 @@ function ExplorerSidebarContent({ [serverId, workspaceId, workspaceRoot], ); - const headerStyle = useMemo( - () => [styles.header, { paddingRight: padding.right }], - [padding.right], - ); - return ( {/* Header with tabs and close button */} - + {isGit && ( @@ -376,7 +365,7 @@ function ExplorerSidebarContent({ )} - + {/* Content based on active tab */} @@ -476,7 +465,6 @@ const styles = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", justifyContent: "space-between", - paddingHorizontal: theme.spacing[2], borderBottomWidth: 1, borderBottomColor: theme.colors.border, }, diff --git a/packages/app/src/components/headers/screen-header.tsx b/packages/app/src/components/headers/screen-header.tsx index ce2ca302f..b8f078e15 100644 --- a/packages/app/src/components/headers/screen-header.tsx +++ b/packages/app/src/components/headers/screen-header.tsx @@ -9,7 +9,7 @@ import { HEADER_TOP_PADDING_MOBILE, useIsCompactFormFactor, } from "@/constants/layout"; -import { useWindowControlsPadding } from "@/utils/desktop-window"; +import { WindowChromeSafeArea } from "@/utils/desktop-window"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; interface ScreenHeaderProps { @@ -18,7 +18,6 @@ interface ScreenHeaderProps { leftStyle?: StyleProp; rightStyle?: StyleProp; borderless?: boolean; - windowControlsPaddingRole?: "header" | "detailHeader"; onRowLayout?: (event: LayoutChangeEvent) => void; } @@ -32,13 +31,11 @@ export function ScreenHeader({ leftStyle, rightStyle, borderless, - windowControlsPaddingRole = "header", onRowLayout, }: ScreenHeaderProps) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const isMobile = useIsCompactFormFactor(); - const padding = useWindowControlsPadding(windowControlsPaddingRole); // Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; const baseHorizontalPadding = theme.spacing[2]; @@ -47,28 +44,23 @@ export function ScreenHeader({ () => [styles.inner, { paddingTop: insets.top + topPadding }], [insets.top, topPadding], ); - const rowStyle = useMemo( - () => [ - styles.row, - { - paddingLeft: baseHorizontalPadding + padding.left, - paddingRight: baseHorizontalPadding + padding.right, - }, - borderless && styles.borderless, - ], - [baseHorizontalPadding, padding.left, padding.right, borderless], - ); + const rowStyle = useMemo(() => [styles.row, borderless && styles.borderless], [borderless]); const leftCombinedStyle = useMemo(() => [styles.left, leftStyle], [leftStyle]); const rightCombinedStyle = useMemo(() => [styles.right, rightStyle], [rightStyle]); return ( - + {left} {right} - + ); @@ -88,7 +80,6 @@ const styles = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", justifyContent: "space-between", - paddingHorizontal: theme.spacing[2], borderBottomWidth: theme.borderWidth[1], borderBottomColor: theme.colors.border, userSelect: "none", diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index db0145b47..25b957ed8 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -25,6 +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/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"; @@ -50,13 +51,8 @@ import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useHosts } from "@/runtime/host-runtime"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useWorkspace } from "@/stores/session-store-hooks"; -import { - MAX_SIDEBAR_WIDTH, - MIN_SIDEBAR_WIDTH, - selectIsAgentListOpen, - usePanelStore, -} from "@/stores/panel-store"; -import { useWindowControlsPadding } from "@/utils/desktop-window"; +import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store"; +import { WindowChromeSafeArea } from "@/utils/desktop-window"; import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; import { @@ -73,8 +69,6 @@ import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarCalloutSlot } from "./sidebar-callout-slot"; import { SidebarWorkspaceList } from "./sidebar-workspace-list"; -const MIN_CHAT_WIDTH = 400; - type SidebarTheme = ReturnType["theme"]; interface SidebarSharedProps { @@ -603,6 +597,7 @@ function MobileSidebar({ panelStyle={mobileSidebarInsetStyle} > + - - {({ hovered, pressed }) => ( - - )} - + + + {({ hovered, pressed }) => ( + + )} + + {isInitialLoad && !hasActiveHostFilter ? ( @@ -713,47 +710,47 @@ function DesktopSidebar({ const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0); const isSessionsActive = pathname.includes("/sessions"); const isSchedulesActive = pathname.includes("/schedules"); - const padding = useWindowControlsPadding("sidebar"); const sidebarWidth = usePanelStore((state) => state.sidebarWidth); const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth); const { width: viewportWidth } = useWindowDimensions(); + const visibleSidebarWidth = resolveDesktopSidebarWidth({ + requestedWidth: sidebarWidth, + viewportWidth, + }); - const startWidthRef = useRef(sidebarWidth); - const resizeWidth = useSharedValue(sidebarWidth); + const startWidthRef = useRef(visibleSidebarWidth); + const resizeWidth = useSharedValue(visibleSidebarWidth); useEffect(() => { - resizeWidth.value = sidebarWidth; - }, [sidebarWidth, resizeWidth]); + resizeWidth.value = visibleSidebarWidth; + }, [resizeWidth, visibleSidebarWidth]); const resizeGesture = useMemo( () => Gesture.Pan() .hitSlop({ left: 8, right: 8, top: 0, bottom: 0 }) .onStart(() => { - startWidthRef.current = sidebarWidth; - resizeWidth.value = sidebarWidth; + startWidthRef.current = visibleSidebarWidth; + resizeWidth.value = visibleSidebarWidth; }) .onUpdate((event) => { // Dragging right (positive translationX) increases width const newWidth = startWidthRef.current + event.translationX; - const maxWidth = Math.max( - MIN_SIDEBAR_WIDTH, - Math.min(MAX_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH), - ); - const clampedWidth = Math.max(MIN_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth)); - resizeWidth.value = clampedWidth; + resizeWidth.value = resolveDesktopSidebarWidth({ + requestedWidth: newWidth, + viewportWidth, + }); }) .onEnd(() => { runOnJS(setSidebarWidth)(resizeWidth.value); }), - [sidebarWidth, resizeWidth, setSidebarWidth, viewportWidth], + [resizeWidth, setSidebarWidth, viewportWidth, visibleSidebarWidth], ); const resizeAnimatedStyle = useAnimatedStyle(() => ({ width: resizeWidth.value, })); - const paddingTopSpacerStyle = useMemo(() => ({ height: padding.top }), [padding.top]); const desktopSidebarStyle = useMemo( () => [staticStyles.desktopSidebar, resizeAnimatedStyle], [resizeAnimatedStyle], @@ -776,7 +773,7 @@ function DesktopSidebar({ - {padding.top > 0 ? : null} + ({ flex: 1, minHeight: 0, }, - mobileCloseButton: { + mobileCloseButtonRow: { position: "absolute", top: theme.spacing[3], - right: theme.spacing[4], + left: 0, + right: 0, zIndex: 2, + alignItems: "flex-end", + pointerEvents: "box-none", + }, + mobileCloseButton: { + marginRight: theme.spacing[4], width: 32, height: 32, alignItems: "center", diff --git a/packages/app/src/components/split-container-focus.test.ts b/packages/app/src/components/split-container-focus.test.ts new file mode 100644 index 000000000..5e67d784a --- /dev/null +++ b/packages/app/src/components/split-container-focus.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { resolveSplitContainerRoot } from "@/components/split-container-focus"; +import type { SplitNode } from "@/stores/workspace-layout-store"; + +const pane = (id: string): SplitNode => ({ + kind: "pane", + pane: { id, tabIds: [], focusedTabId: null }, +}); +const root: SplitNode = { + kind: "group", + group: { + id: "root", + direction: "horizontal", + children: [pane("left"), pane("right")], + sizes: [0.5, 0.5], + }, +}; + +describe("split focus root", () => { + it("renders only the valid focused pane in focus mode", () => { + expect( + resolveSplitContainerRoot({ root, focusedPaneId: "right", focusModeEnabled: true }), + ).toEqual({ root: pane("right"), usesFallbackStrip: false }); + }); + + it("keeps the full tree and reserves the boundary strip when focus is missing", () => { + expect( + resolveSplitContainerRoot({ root, focusedPaneId: "missing", focusModeEnabled: true }), + ).toEqual({ root, usesFallbackStrip: true }); + }); + + it("keeps normal splits unclaimed", () => { + expect( + resolveSplitContainerRoot({ root, focusedPaneId: "right", focusModeEnabled: false }), + ).toEqual({ root, usesFallbackStrip: false }); + }); +}); diff --git a/packages/app/src/components/split-container-focus.ts b/packages/app/src/components/split-container-focus.ts new file mode 100644 index 000000000..e16f4571d --- /dev/null +++ b/packages/app/src/components/split-container-focus.ts @@ -0,0 +1,21 @@ +import type { SplitNode, SplitPane } from "@/stores/workspace-layout-store"; + +export function resolveSplitContainerRoot(input: { + root: SplitNode; + focusedPaneId: string | null; + focusModeEnabled: boolean | undefined; +}): { root: SplitNode; usesFallbackStrip: boolean } { + if (!input.focusModeEnabled) return { root: input.root, usesFallbackStrip: false }; + const focusedPane = input.focusedPaneId ? findPane(input.root, input.focusedPaneId) : null; + if (!focusedPane) return { root: input.root, usesFallbackStrip: true }; + return { root: { kind: "pane", pane: focusedPane }, usesFallbackStrip: false }; +} + +function findPane(node: SplitNode, paneId: string): SplitPane | null { + if (node.kind === "pane") return node.pane.id === paneId ? node.pane : null; + for (const child of node.group.children) { + const pane = findPane(child, paneId); + if (pane) return pane; + } + return null; +} diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index bf90d1f98..c9e4294f9 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -32,8 +32,14 @@ import { useTranslation } from "react-i18next"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ResizeHandle } from "@/components/resize-handle"; import { RetainedPanel } from "@/components/retained-panel"; +import { resolveSplitContainerRoot } from "@/components/split-container-focus"; import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus"; -import { useWindowControlsPadding } from "@/utils/desktop-window"; +import { + WindowChromeRegion, + WindowChromeSafeArea, + useWindowChromeCorners, + type WindowChromeCorners, +} from "@/utils/desktop-window"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { computeTabDropPreview, @@ -158,6 +164,7 @@ interface SplitNodeViewProps extends Omit { pane: SplitPane; uiTabs: WorkspaceTab[]; @@ -383,6 +391,8 @@ export function SplitContainer({ renderPaneEmptyState = () => null, focusModeEnabled, }: SplitContainerProps) { + const inheritedWindowChromeCorners = useWindowChromeCorners(); + const windowChromeCorners = focusModeEnabled ? inheritedWindowChromeCorners : "none"; const [activeDragTabId, setActiveDragTabId] = useState(null); const [dropPreview, setDropPreview] = useState(null); const [tabDropPreview, setTabDropPreview] = useState(null); @@ -399,18 +409,16 @@ export function SplitContainer({ ); const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]); - - const effectiveRoot = useMemo(() => { - if (!focusModeEnabled) { - return layout.root; - } - const focusedPane = layout.focusedPaneId ? panesById.get(layout.focusedPaneId) : null; - if (!focusedPane) { - return layout.root; - } - return { kind: "pane" as const, pane: focusedPane }; - }, [focusModeEnabled, layout.root, layout.focusedPaneId, panesById]); - const renderRoot = useMemo(() => wrapRootPaneForStableMount(effectiveRoot), [effectiveRoot]); + const splitRoot = useMemo( + () => + resolveSplitContainerRoot({ + root: layout.root, + focusedPaneId: layout.focusedPaneId, + focusModeEnabled, + }), + [focusModeEnabled, layout.focusedPaneId, layout.root], + ); + const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]); const handleDragStart = useCallback((event: DragStartEvent) => { const data = asWorkspaceTabDragData(event.active.data.current); @@ -565,6 +573,7 @@ export function SplitContainer({ onDragCancel={handleDragCancel} onDragEnd={handleDragEnd} > + {splitRoot.usesFallbackStrip && } {activeDragTabId ? ( @@ -744,6 +754,7 @@ function SplitNodeView({ showDropZones, dropPreview, tabDropPreview, + windowChromeCorners, }: SplitNodeViewProps) { const groupId = node.kind === "group" ? node.group.id : null; const groupDirection = node.kind === "group" ? node.group.direction : null; @@ -762,41 +773,43 @@ function SplitNodeView({ if (node.kind === "pane") { return ( - + + + ); } @@ -843,6 +856,7 @@ function SplitNodeView({ showDropZones={showDropZones} dropPreview={dropPreview} tabDropPreview={tabDropPreview} + windowChromeCorners={windowChromeCorners} /> {index < node.group.children.length - 1 ? ( @@ -898,7 +912,6 @@ function SplitPaneView({ const { theme: _theme } = useUnistyles(); const paneRef = useRef(null); const stableOnFocusPane = useStableEvent(onFocusPane); - const padding = useWindowControlsPadding("tabRow"); const paneState = useMemo( () => deriveWorkspacePaneState({ @@ -995,15 +1008,11 @@ function SplitPaneView({ () => onSplitPaneEmpty({ targetPaneId: paneId, position: "bottom" }), [onSplitPaneEmpty, paneId], ); - const paneTabsStyle = useMemo( - () => [styles.paneTabs, { paddingLeft: padding.left, paddingRight: padding.right }], - [padding.left, padding.right], - ); return ( - + - + {mountedPaneTabIds.length > 0 diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts index cf77d5dc0..676f1bb86 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/desktop/host.ts b/packages/app/src/desktop/host.ts index f76a2c528..768552b92 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -97,6 +97,7 @@ export interface DesktopWindowControlsOverlayUpdate { export interface DesktopWindowBridge { label?: string; toggleMaximize?: () => Promise; + setFullscreen?: (fullscreen: boolean) => Promise; isFullscreen?: () => Promise; updateWindowControls?: (update: DesktopWindowControlsOverlayUpdate) => Promise; onResized?: ( diff --git a/packages/app/src/mobile-panels/presentation.tsx b/packages/app/src/mobile-panels/presentation.tsx index 2a6cdf950..ca0987756 100644 --- a/packages/app/src/mobile-panels/presentation.tsx +++ b/packages/app/src/mobile-panels/presentation.tsx @@ -3,6 +3,7 @@ import { Pressable, StyleSheet, View } from "react-native"; import { GestureDetector, type GestureType } from "react-native-gesture-handler"; import Animated, { useAnimatedStyle } from "react-native-reanimated"; import { isWeb } from "@/constants/platform"; +import { WindowChromeRootRegion } from "@/utils/desktop-window"; import { usePanelStore, type MobilePanelView } from "@/stores/panel-store"; import { getMobilePanelFrame } from "./model"; import { useIsMobilePanelPresented, useMobilePanelsRuntime } from "./provider"; @@ -82,7 +83,7 @@ export function MobilePanelOverlay({ - {children} + {children} diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index b5e2bf16e..6267a4fa1 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -57,7 +57,7 @@ import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; -import { useWindowControlsPadding } from "@/utils/desktop-window"; +import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window"; import { confirmDialog } from "@/utils/confirm-dialog"; import { BackHeader } from "@/components/headers/back-header"; import { ScreenHeader } from "@/components/headers/screen-header"; @@ -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, @@ -992,7 +992,6 @@ function SettingsSidebar({ const isDesktopApp = isElectronRuntime(); const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp); const insets = useSafeAreaInsets(); - const padding = useWindowControlsPadding("sidebar"); const isDesktop = layout === "desktop"; const outerContainerStyle = useMemo( () => [isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer], @@ -1005,7 +1004,6 @@ function SettingsSidebar({ const selectedSectionId = view.kind === "section" ? view.section : null; const selectedHostSection = view.kind === "host" ? view.section : null; const isProjectsSelected = view.kind === "projects" || view.kind === "project"; - const paddingTopStyle = useMemo(() => ({ height: padding.top }), [padding.top]); const sidebarBody = ( <> @@ -1087,7 +1085,7 @@ function SettingsSidebar({ - {padding.top > 0 ? : null} + + + + + {detailHeader.title} + {detailHeader.titleAccessory} + + ) : null; + const addHostModals = ( <> - - - - - - - - {detailHeader.title} - - {detailHeader.titleAccessory} - - ) : null - } - leftStyle={desktopStyles.detailLeft} + + - - {content} - - + + + + + + {content} + + + {addHostModals} @@ -1654,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/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index b35b3a1b4..1ae7c0778 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -6,6 +6,7 @@ import { useRef, useState, type ReactElement, + type ComponentProps, type ReactNode, } from "react"; import { useStoreWithEqualityFn } from "zustand/traditional"; @@ -61,6 +62,7 @@ import { import { ExplorerSidebar } from "@/components/explorer-sidebar"; import { SplitContainer } from "@/components/split-container"; import { RetainedPanel } from "@/components/retained-panel"; +import { WindowChromeRegion } from "@/utils/desktop-window"; import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon"; import { WorkspaceActions } from "@/git/workspace-actions"; import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button"; @@ -1566,6 +1568,46 @@ function shouldShowWorkspaceExplorerSidebar(input: { return !input.isMobile && input.isRouteFocused && shouldShowWorkspaceScreenHeader(input); } +interface WorkspaceChromeRowProps extends Omit< + ComponentProps, + "workspaceRoot" +> { + children: ReactNode; + explorerOpen: boolean; + portalHostName: string; + showExplorerSidebar: boolean; + workspaceRoot: string | null; +} + +function WorkspaceChromeRow({ + children, + explorerOpen, + portalHostName, + showExplorerSidebar, + workspaceRoot, + ...explorerProps +}: WorkspaceChromeRowProps) { + const explorerRendered = showExplorerSidebar && explorerOpen && workspaceRoot !== null; + + return ( + + + + {children} + + + + + + {showExplorerSidebar && workspaceRoot ? ( + + + + ) : null} + + ); +} + function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null { if (!serverId || !workspaceId) { return null; @@ -3624,23 +3666,18 @@ function WorkspaceScreenContent({ workspaceId={normalizedWorkspaceId} isRouteFocused={isRouteFocused} /> - - - {workspaceCenterColumn} - - - - - {showExplorerSidebar && workspaceDirectory ? ( - - ) : null} - + + {workspaceCenterColumn} + { - it("keeps mac traffic-light padding available when the app window is not fullscreen", () => { +describe("window chrome", () => { + it("has no corner obstruction outside Electron or in fullscreen", () => { expect( - resolveRawWindowControlsPadding({ isElectron: true, isMac: true, isFullscreen: false }), - ).toEqual({ - left: 78, - right: 0, - top: 45, - }); + resolveWindowChromeObstruction({ isElectron: false, isMac: true, isFullscreen: false }), + ).toEqual({ topLeft: null, topRight: null }); + expect( + resolveWindowChromeObstruction({ isElectron: true, isMac: true, isFullscreen: true }), + ).toEqual({ topLeft: null, topRight: null }); }); - it("keeps Windows and Linux window-control padding available when the app window is not fullscreen", () => { + it("places native controls in their physical top corner", () => { expect( - resolveRawWindowControlsPadding({ isElectron: true, isMac: false, isFullscreen: false }), - ).toEqual({ - left: 0, - right: 140, - top: 48, - }); + resolveWindowChromeObstruction({ isElectron: true, isMac: true, isFullscreen: false }), + ).toEqual({ topLeft: { width: 78, height: 45 }, topRight: null }); + expect( + resolveWindowChromeObstruction({ isElectron: true, isMac: false, isFullscreen: false }), + ).toEqual({ topLeft: null, topRight: { width: 140, height: 48 } }); }); - it("does not reserve window-control padding when the app window is fullscreen", () => { + it("insets and reserves only claimed corners", () => { + const obstruction = { topLeft: { width: 80, height: 28 }, topRight: { width: 48, height: 32 } }; expect( - resolveRawWindowControlsPadding({ isElectron: true, isMac: true, isFullscreen: true }), - ).toEqual({ - left: 0, - right: 0, - top: 0, - }); + resolveWindowChromeSafeArea({ obstruction, corners: "top-left", placement: "inline" }), + ).toEqual({ paddingLeft: 80, paddingRight: 0 }); + expect( + resolveWindowChromeSafeArea({ obstruction, corners: "top-right", placement: "below" }), + ).toEqual({ height: 32 }); + expect( + resolveWindowChromeSafeArea({ obstruction, corners: "both", placement: "below" }), + ).toEqual({ height: 32 }); + expect( + resolveWindowChromeSafeArea({ obstruction, corners: "top-right", placement: "inline" }), + ).toEqual({ paddingLeft: 0, paddingRight: 48 }); }); - it("pads the main header for window controls when the app sidebar is closed", () => { - expect( - resolveWindowControlsPadding({ - role: "header", - rawPadding, - sidebarClosed: true, - explorerOpen: false, - focusModeEnabled: false, - }), - ).toEqual({ - left: 80, - right: 48, - top: 0, - }); - }); - - it("does not add left padding to detail headers with their own sidebar", () => { - expect( - resolveWindowControlsPadding({ - role: "detailHeader", - rawPadding, - sidebarClosed: true, - explorerOpen: false, - focusModeEnabled: false, - }), - ).toEqual({ - left: 0, - right: 48, - top: 0, - }); - }); - - it("pads a focus-mode tab row away from mac traffic lights even when the sidebar is logically open", () => { - expect( - resolveWindowControlsPadding({ - role: "tabRow", - rawPadding, - sidebarClosed: false, - explorerOpen: false, - focusModeEnabled: true, - }), - ).toEqual({ - left: 80, - right: 48, - top: 0, - }); - }); - - it("pads a focus-mode tab row away from right-side window controls even when the explorer is logically open", () => { - expect( - resolveWindowControlsPadding({ - role: "tabRow", - rawPadding: { left: 0, right: 140, top: 48 }, - sidebarClosed: true, - explorerOpen: true, - focusModeEnabled: true, - }), - ).toEqual({ - left: 0, - right: 140, - top: 0, - }); + it("intersects identical and empty corner claims", () => { + expect(intersectWindowChromeCorners("both", "both")).toBe("both"); + expect(intersectWindowChromeCorners("top-left", "top-left")).toBe("top-left"); + expect(intersectWindowChromeCorners("none", "both")).toBe("none"); + expect(intersectWindowChromeCorners("both", "none")).toBe("none"); + expect(intersectWindowChromeCorners("both", "top-left")).toBe("top-left"); + expect(intersectWindowChromeCorners("top-right", "both")).toBe("top-right"); + expect(intersectWindowChromeCorners("top-left", "top-right")).toBe("none"); }); }); diff --git a/packages/app/src/utils/desktop-window.ts b/packages/app/src/utils/desktop-window.ts deleted file mode 100644 index c939a5745..000000000 --- a/packages/app/src/utils/desktop-window.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { - getIsElectronRuntimeMac, - getIsElectronRuntime, - DESKTOP_TRAFFIC_LIGHT_WIDTH, - DESKTOP_TRAFFIC_LIGHT_HEIGHT, - DESKTOP_WINDOW_CONTROLS_WIDTH, - DESKTOP_WINDOW_CONTROLS_HEIGHT, -} from "@/constants/layout"; -import { getDesktopWindow } from "@/desktop/electron/window"; -import { usePanelStore } from "@/stores/panel-store"; -import { isNative } from "@/constants/platform"; - -interface RawWindowControlsPadding { - left: number; - right: number; - top: number; -} - -type WindowControlsPaddingRole = - | "sidebar" - | "header" - | "detailHeader" - | "tabRow" - | "explorerSidebar"; - -// Module-level cache so hook remounts (e.g., on navigation) don't briefly -// fall back to the default `false` while the async fullscreen check resolves. -// Without this, in fullscreen the sidebar flashes with traffic-light padding -// on first frame and then snaps to 0 once the async read completes. -let cachedIsFullscreen = false; -const fullscreenSubscribers = new Set<(value: boolean) => void>(); -let fullscreenSubscriptionStarted = false; - -function setCachedFullscreen(value: boolean) { - if (cachedIsFullscreen === value) return; - cachedIsFullscreen = value; - for (const sub of fullscreenSubscribers) { - sub(value); - } -} - -function startFullscreenSubscription() { - if (fullscreenSubscriptionStarted) return; - if (isNative || !getIsElectronRuntime()) return; - fullscreenSubscriptionStarted = true; - - void (async () => { - const win = getDesktopWindow(); - if (!win) return; - - if (typeof win.isFullscreen === "function") { - try { - setCachedFullscreen(await win.isFullscreen()); - } catch (error) { - console.warn("[DesktopWindow] Failed to read fullscreen state", error); - } - } - - if (typeof win.onResized !== "function") return; - - try { - await win.onResized(async () => { - if (typeof win.isFullscreen !== "function") return; - try { - setCachedFullscreen(await win.isFullscreen()); - } catch (error) { - console.warn("[DesktopWindow] Failed to read fullscreen state", error); - } - }); - } catch (error) { - console.warn("[DesktopWindow] Failed to subscribe to resize", error); - } - })(); -} - -function useRawWindowControlsPadding(): RawWindowControlsPadding { - const [isFullscreen, setIsFullscreen] = useState(cachedIsFullscreen); - - useEffect(() => { - startFullscreenSubscription(); - // Sync to any value that resolved between render and effect. - setIsFullscreen(cachedIsFullscreen); - fullscreenSubscribers.add(setIsFullscreen); - return () => { - fullscreenSubscribers.delete(setIsFullscreen); - }; - }, []); - - return resolveRawWindowControlsPadding({ - isElectron: getIsElectronRuntime(), - isMac: getIsElectronRuntimeMac(), - isFullscreen, - }); -} - -export function resolveRawWindowControlsPadding(input: { - isElectron: boolean; - isMac: boolean; - isFullscreen: boolean; -}): RawWindowControlsPadding { - if (!input.isElectron || input.isFullscreen) { - return { left: 0, right: 0, top: 0 }; - } - - if (input.isMac) { - return { - left: DESKTOP_TRAFFIC_LIGHT_WIDTH, - right: 0, - top: DESKTOP_TRAFFIC_LIGHT_HEIGHT, - }; - } - - return { - left: 0, - right: DESKTOP_WINDOW_CONTROLS_WIDTH, - top: DESKTOP_WINDOW_CONTROLS_HEIGHT, - }; -} - -export function useWindowControlsPadding(role: WindowControlsPaddingRole): { - left: number; - right: number; - top: number; -} { - const sidebarOpen = usePanelStore((state) => state.desktop.agentListOpen); - const explorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); - const focusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); - const rawPadding = useRawWindowControlsPadding(); - const sidebarClosed = !sidebarOpen; - - const { left, right, top } = resolveWindowControlsPadding({ - role, - rawPadding, - sidebarClosed, - explorerOpen, - focusModeEnabled, - }); - - return useMemo(() => ({ left, right, top }), [left, right, top]); -} - -export function resolveWindowControlsPadding(input: { - role: WindowControlsPaddingRole; - rawPadding: RawWindowControlsPadding; - sidebarClosed: boolean; - explorerOpen: boolean; - focusModeEnabled: boolean; -}): RawWindowControlsPadding { - if (input.role === "sidebar") { - return { - left: input.rawPadding.left, - right: 0, - top: input.rawPadding.top, - }; - } - - if (input.role === "header") { - return { - left: input.sidebarClosed ? input.rawPadding.left : 0, - right: input.explorerOpen ? 0 : input.rawPadding.right, - top: 0, - }; - } - - if (input.role === "detailHeader") { - return { - left: 0, - right: input.rawPadding.right, - top: 0, - }; - } - - if (input.role === "tabRow") { - return { - left: input.focusModeEnabled ? input.rawPadding.left : 0, - right: input.focusModeEnabled ? input.rawPadding.right : 0, - top: 0, - }; - } - - return { - left: 0, - right: input.rawPadding.right, - top: 0, - }; -} diff --git a/packages/app/src/utils/desktop-window.tsx b/packages/app/src/utils/desktop-window.tsx new file mode 100644 index 000000000..4af5e8733 --- /dev/null +++ b/packages/app/src/utils/desktop-window.tsx @@ -0,0 +1,238 @@ +import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +import { View, type ViewProps } from "react-native"; +import { + DESKTOP_TRAFFIC_LIGHT_HEIGHT, + DESKTOP_TRAFFIC_LIGHT_WIDTH, + DESKTOP_WINDOW_CONTROLS_HEIGHT, + DESKTOP_WINDOW_CONTROLS_WIDTH, + getIsElectronRuntime, + getIsElectronRuntimeMac, +} from "@/constants/layout"; +import { getDesktopWindow } from "@/desktop/electron/window"; +import { isNative } from "@/constants/platform"; + +export type WindowChromeCorners = "none" | "top-left" | "top-right" | "both"; +type WindowChromeSafeAreaPlacement = "inline" | "below"; + +interface WindowChromeCornerObstruction { + width: number; + height: number; +} + +interface WindowChromeObstruction { + topLeft: WindowChromeCornerObstruction | null; + topRight: WindowChromeCornerObstruction | null; +} + +type WindowChromeSafeAreaStyle = { height: number } | { paddingLeft: number; paddingRight: number }; + +const EMPTY_OBSTRUCTION: WindowChromeObstruction = { topLeft: null, topRight: null }; +const WindowChromeContext = createContext(EMPTY_OBSTRUCTION); +const WindowChromeCornersContext = createContext("none"); + +function windowChromeCornersFromFlags(topLeft: boolean, topRight: boolean): WindowChromeCorners { + if (topLeft && topRight) return "both"; + if (topLeft) return "top-left"; + if (topRight) return "top-right"; + return "none"; +} + +export function intersectWindowChromeCorners( + inherited: WindowChromeCorners, + declared: WindowChromeCorners, +): WindowChromeCorners { + const inheritedTopLeft = inherited === "top-left" || inherited === "both"; + const inheritedTopRight = inherited === "top-right" || inherited === "both"; + const declaredTopLeft = declared === "top-left" || declared === "both"; + const declaredTopRight = declared === "top-right" || declared === "both"; + return windowChromeCornersFromFlags( + inheritedTopLeft && declaredTopLeft, + inheritedTopRight && declaredTopRight, + ); +} + +export function resolveWindowChromeObstruction(input: { + isElectron: boolean; + isMac: boolean; + isFullscreen: boolean; +}): WindowChromeObstruction { + if (!input.isElectron || input.isFullscreen) return EMPTY_OBSTRUCTION; + if (input.isMac) { + return { + topLeft: { width: DESKTOP_TRAFFIC_LIGHT_WIDTH, height: DESKTOP_TRAFFIC_LIGHT_HEIGHT }, + topRight: null, + }; + } + return { + topLeft: null, + topRight: { width: DESKTOP_WINDOW_CONTROLS_WIDTH, height: DESKTOP_WINDOW_CONTROLS_HEIGHT }, + }; +} + +export function resolveWindowChromeSafeArea(input: { + obstruction: WindowChromeObstruction; + corners: WindowChromeCorners; + placement: WindowChromeSafeAreaPlacement; +}): WindowChromeSafeAreaStyle { + const ownsTopLeft = input.corners === "top-left" || input.corners === "both"; + const ownsTopRight = input.corners === "top-right" || input.corners === "both"; + const topLeft = ownsTopLeft ? input.obstruction.topLeft : null; + const topRight = ownsTopRight ? input.obstruction.topRight : null; + if (input.placement === "below") { + return { height: Math.max(topLeft?.height ?? 0, topRight?.height ?? 0) }; + } + return { paddingLeft: topLeft?.width ?? 0, paddingRight: topRight?.width ?? 0 }; +} + +export function WindowChromeProvider({ children }: { children: ReactNode }) { + const [isElectronReady, setIsElectronReady] = useState(getIsElectronRuntime); + const [isFullscreen, setIsFullscreen] = useState(false); + + useEffect(() => { + let active = true; + let dispose: (() => void) | undefined; + let connecting = false; + let retryCount = 0; + let retryTimer: ReturnType | undefined; + + function scheduleRetry(warnOnExhaustion = false) { + if (!active || dispose || retryTimer) return; + if (retryCount >= 40) { + if (warnOnExhaustion) { + console.warn("[DesktopWindow] Chrome bridge unavailable; window controls may overlap UI"); + } + return; + } + retryCount += 1; + retryTimer = setTimeout(() => { + retryTimer = undefined; + connect(); + }, 250); + } + + function connect() { + if (!active || dispose || connecting) return; + if (!getIsElectronRuntime()) return scheduleRetry(); + const desktopWindow = getDesktopWindow(); + if ( + !desktopWindow || + typeof desktopWindow.isFullscreen !== "function" || + typeof desktopWindow.onResized !== "function" + ) + return scheduleRetry(true); + const readFullscreen = desktopWindow.isFullscreen; + const subscribeToResized = desktopWindow.onResized; + connecting = true; + void (async () => { + async function syncFullscreen() { + try { + const fullscreen = await readFullscreen(); + if (active) setIsFullscreen(fullscreen); + } catch (error) { + if (active) console.warn("[DesktopWindow] Failed to read fullscreen state", error); + } + } + try { + const nextDispose = await subscribeToResized(syncFullscreen); + if (!active) return nextDispose(); + dispose = nextDispose; + setIsElectronReady(true); + await syncFullscreen(); + } catch (error) { + if (active) console.warn("[DesktopWindow] Failed to subscribe to resize", error); + } finally { + connecting = false; + if (!dispose) scheduleRetry(); + } + })(); + } + + if (!isNative) connect(); + + return () => { + active = false; + if (retryTimer) clearTimeout(retryTimer); + dispose?.(); + }; + }, []); + + const obstruction = useMemo( + () => + resolveWindowChromeObstruction({ + isElectron: isElectronReady, + isMac: getIsElectronRuntimeMac(), + isFullscreen, + }), + [isElectronReady, isFullscreen], + ); + return ( + + + {children} + + + ); +} + +/** Narrows inherited corner ownership to the corners occupied by this child surface. */ +export function WindowChromeRegion({ + corners, + children, +}: { + corners: WindowChromeCorners; + children: ReactNode; +}) { + const inheritedCorners = useContext(WindowChromeCornersContext); + const ownedCorners = intersectWindowChromeCorners(inheritedCorners, corners); + return ( + + {children} + + ); +} + +/** Restarts ownership for a new physical viewport such as a Modal or full-window overlay. */ +export function WindowChromeRootRegion({ + corners, + children, +}: { + corners: WindowChromeCorners; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useWindowChromeCorners(): WindowChromeCorners { + return useContext(WindowChromeCornersContext); +} + +type WindowChromeSafeAreaProps = ViewProps & { + placement: WindowChromeSafeAreaPlacement; + horizontalPadding?: number; +}; + +export function WindowChromeSafeArea({ + placement, + horizontalPadding = 0, + style, + ...props +}: WindowChromeSafeAreaProps) { + const obstruction = useContext(WindowChromeContext); + const corners = useContext(WindowChromeCornersContext); + const safeAreaStyle = useMemo(() => { + const resolved = resolveWindowChromeSafeArea({ obstruction, corners, placement }); + if (placement === "below") return resolved; + const paddingLeft = "paddingLeft" in resolved ? resolved.paddingLeft : 0; + const paddingRight = "paddingRight" in resolved ? resolved.paddingRight : 0; + return { + paddingLeft: paddingLeft + horizontalPadding, + paddingRight: paddingRight + horizontalPadding, + }; + }, [corners, horizontalPadding, obstruction, placement]); + const combinedStyle = useMemo(() => [style, safeAreaStyle], [safeAreaStyle, style]); + return ; +} diff --git a/packages/desktop/package.json b/packages/desktop/package.json index eb916638c..bcbe566cc 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -20,6 +20,7 @@ "capture-harness": "./capture-harness/run.sh", "dev": "./scripts/dev.sh", "dev:win": "powershell ./scripts/dev.ps1", + "verify:electron-cdp": "node ./scripts/verify-electron-cdp.mjs", "test": "vitest run", "typecheck": "tsgo --noEmit -p tsconfig.json" }, diff --git a/packages/desktop/scripts/verify-electron-cdp.mjs b/packages/desktop/scripts/verify-electron-cdp.mjs index 0a795c2a7..d37e13f41 100644 --- a/packages/desktop/scripts/verify-electron-cdp.mjs +++ b/packages/desktop/scripts/verify-electron-cdp.mjs @@ -3,9 +3,11 @@ import path from "node:path"; import process from "node:process"; import { chromium } from "playwright"; -const CDP_URL = process.env.CDP_URL ?? "http://127.0.0.1:9223"; +const CDP_PORT = process.env.PASEO_ELECTRON_REMOTE_DEBUGGING_PORT ?? "9223"; +const EXPO_PORT = process.env.EXPO_PORT ?? "8082"; +const CDP_URL = process.env.CDP_URL ?? `http://127.0.0.1:${CDP_PORT}`; const OUTPUT_DIR = process.env.ELECTRON_VERIFY_OUTPUT_DIR ?? "/tmp/electron-verification"; -const APP_URL_FRAGMENT = process.env.ELECTRON_VERIFY_APP_URL_FRAGMENT ?? "localhost:8081"; +const APP_URL_FRAGMENT = process.env.ELECTRON_VERIFY_APP_URL_FRAGMENT ?? `localhost:${EXPO_PORT}`; const REQUIRED_DESKTOP_KEYS = ["invoke", "events", "window", "dialog", "notification", "opener"]; const INTERACTIVE_SELECTOR = [ "button", @@ -32,10 +34,6 @@ function assert(condition, message) { } } -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - async function ensureDir(dirPath) { await fs.mkdir(dirPath, { recursive: true }); } @@ -46,6 +44,96 @@ async function captureScreenshot(page, fileName) { return filePath; } +function rectsIntersect(left, right) { + return ( + left.left < right.left + right.width && + left.left + left.width > right.left && + left.top < right.top + right.height && + left.top + left.height > right.top + ); +} + +function getWindowChromeObstruction(platform, innerWidth) { + if (platform === "darwin") { + return { corner: "top-left", left: 0, top: 0, width: 78, height: 45 }; + } + return { + corner: "top-right", + left: innerWidth - 140, + top: 0, + width: 140, + height: 48, + }; +} + +async function inspectSettingsGeometry(page) { + return page.evaluate(() => { + function rect(selector) { + const element = document.querySelector(selector); + if (!(element instanceof HTMLElement)) return null; + const bounds = element.getBoundingClientRect(); + return { + left: bounds.left, + top: bounds.top, + width: bounds.width, + height: bounds.height, + }; + } + + const title = document.querySelector('[data-testid="settings-detail-header-title"]'); + const headerLeft = title instanceof HTMLElement ? title.parentElement : null; + const headerLeftBounds = headerLeft?.getBoundingClientRect() ?? null; + + return { + innerWidth: window.innerWidth, + innerHeight: window.innerHeight, + 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 + ? { + left: headerLeftBounds.left, + top: headerLeftBounds.top, + width: headerLeftBounds.width, + height: headerLeftBounds.height, + } + : null, + }; + }); +} + +function settingsGeometryClearsWindowChrome(geometry, platform) { + const obstruction = getWindowChromeObstruction(platform, geometry.innerWidth); + const consumer = platform === "darwin" ? geometry.backButtonRect : geometry.detailHeaderLeftRect; + return Boolean(consumer && !rectsIntersect(consumer, obstruction)); +} + +async function readBridgeFullscreen(page) { + return page.evaluate( + async () => + (await window.paseoDesktop?.window?.getCurrentWindow?.()?.isFullscreen?.()) === true, + ); +} + +async function setNativeFullscreen(page, fullscreen) { + await page.evaluate(async (nextFullscreen) => { + const win = window.paseoDesktop?.window?.getCurrentWindow?.(); + if (typeof win?.setFullscreen !== "function") throw new Error("setFullscreen is unavailable"); + await win.setFullscreen(nextFullscreen); + }, fullscreen); +} + +async function waitForBridgeFullscreen(page, expected) { + for (let attempt = 0; attempt < 50; attempt += 1) { + if ((await readBridgeFullscreen(page)) === expected) return; + await page.waitForTimeout(200); + } + throw new Error(`Timed out waiting for fullscreen=${expected}`); +} + async function inspectTitlebarRegions(page) { return page.evaluate((interactiveSelector) => { const nodes = Array.from(document.querySelectorAll("*")); @@ -259,111 +347,152 @@ async function inspectTitlebarRegions(page) { }, INTERACTIVE_SELECTOR); } -async function inspectFullscreenResizer(page) { - const session = await page.context().newCDPSession(page); - let windowId = null; - let initialBounds = null; - let fullscreenEntered = false; +async function inspectFullscreenWindowChrome(page, platform) { + const initiallyFullscreen = await readBridgeFullscreen(page); try { - const windowInfo = await session.send("Browser.getWindowForTarget"); - windowId = windowInfo.windowId; - initialBounds = await session.send("Browser.getWindowBounds", { windowId }); - await session.send("Browser.setWindowBounds", { - windowId, - bounds: { windowState: "fullscreen" }, - }); - fullscreenEntered = true; - await page.waitForTimeout(1000); + assert(!initiallyFullscreen, "Electron verifier requires a non-fullscreen QA window"); + const before = await inspectSettingsGeometry(page); + await setNativeFullscreen(page, true); + await waitForBridgeFullscreen(page, true); const details = await page.evaluate(async () => { - const bridge = window.paseoDesktop?.window; + const bridge = window.paseoDesktop?.window?.getCurrentWindow?.(); const bridgeFullscreen = typeof bridge?.isFullscreen === "function" ? await bridge.isFullscreen() : null; - const visibleNoDragResizers = Array.from(document.querySelectorAll("*")) - .filter((node) => node instanceof HTMLElement) - .filter((node) => { - const element = node; - const rect = element.getBoundingClientRect(); - const style = window.getComputedStyle(element); - const appRegion = - style.webkitAppRegion || style.getPropertyValue("-webkit-app-region") || "none"; - return ( - rect.width > 0 && - rect.height > 0 && - style.display !== "none" && - style.visibility !== "hidden" && - style.opacity !== "0" && - appRegion === "no-drag" && - style.position === "absolute" && - Math.abs(rect.height - 4) <= 1 && - rect.top < 220 - ); - }) - .map((node) => { - const rect = node.getBoundingClientRect(); - return { - tagName: node.tagName.toLowerCase(), - top: rect.top, - left: rect.left, - width: rect.width, - height: rect.height, - }; - }); - - return { - bridgeFullscreen, - visibleNoDragResizers, - }; + return { bridgeFullscreen }; }); + const fullscreen = await inspectSettingsGeometry(page); + const screenshot = await captureScreenshot(page, "04-fullscreen-window-chrome.png"); + const clearanceRemoved = + platform === "darwin" + ? Boolean( + before.backButtonRect && + fullscreen.backButtonRect && + before.backButtonRect.top >= 45 && + fullscreen.backButtonRect.top < 45 && + fullscreen.backButtonRect.top < before.backButtonRect.top, + ) + : Boolean( + before.detailHeaderLeftRect && + fullscreen.detailHeaderLeftRect && + before.innerWidth - + (before.detailHeaderLeftRect.left + before.detailHeaderLeftRect.width) >= + 140 && + fullscreen.innerWidth - + (fullscreen.detailHeaderLeftRect.left + fullscreen.detailHeaderLeftRect.width) < + 40, + ); return { supported: true, - enteredFullscreen: fullscreenEntered, - initialBounds, + initiallyFullscreen, + before, + fullscreen, + clearanceRemoved, + screenshot, ...details, - passed: - details.bridgeFullscreen === true && - Array.isArray(details.visibleNoDragResizers) && - details.visibleNoDragResizers.length === 0, + passed: details.bridgeFullscreen === true && clearanceRemoved, }; } catch (error) { return { supported: false, error: String(error), - initialBounds, + initiallyFullscreen, }; } finally { - const previousWindowState = initialBounds?.bounds?.windowState ?? "normal"; - if (windowId !== null && fullscreenEntered) { - try { - await session.send("Browser.setWindowBounds", { - windowId, - bounds: { windowState: previousWindowState }, - }); - await page.waitForTimeout(500); - } catch { - // Best-effort restore only. - } + if (await readBridgeFullscreen(page)) { + await setNativeFullscreen(page, false); + await waitForBridgeFullscreen(page, false); } - await session.detach().catch(() => undefined); + } +} + +async function inspectHalfScreenSettingsLayout(page, platform) { + const initialBounds = await page.evaluate(() => ({ + width: window.outerWidth, + height: window.outerHeight, + })); + + try { + await page.evaluate(() => { + // Electron applies resizeTo to the native BrowserWindow. Unlike + // page.setViewportSize, this exercises the real window/layout boundary. + window.resizeTo(751, Math.max(window.outerHeight, 700)); + }); + await page.waitForFunction(() => window.innerWidth === 751, undefined, { timeout: 10_000 }); + + 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); + const clearsWindowChrome = settingsGeometryClearsWindowChrome(details, platform); + const sidebarRight = details.sidebarRect + ? details.sidebarRect.left + details.sidebarRect.width + : null; + const detailRight = details.detailPaneRect + ? details.detailPaneRect.left + details.detailPaneRect.width + : null; + const screenshot = await captureScreenshot(page, "06-half-screen-settings.png"); + + return { + supported: true, + initialBounds, + ...details, + obstruction, + clearsWindowChrome, + screenshot, + passed: + details.innerWidth === 751 && + details.sidebarRect !== null && + 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 && + detailRight !== null && + Math.abs(detailRight - details.innerWidth) <= 1 && + clearsWindowChrome, + }; + } catch (error) { + return { supported: false, initialBounds, error: String(error) }; + } finally { + await page.evaluate((bounds) => window.resizeTo(bounds.width, bounds.height), initialBounds); + await page.waitForFunction( + (width) => Math.abs(window.outerWidth - width) <= 1, + initialBounds.width, + { timeout: 10_000 }, + ); } } async function findAppPage(browser) { - function findMatchingPage() { + function findMatchingPages() { + const matches = []; for (const context of browser.contexts()) { for (const page of context.pages()) { if (page.url().includes(APP_URL_FRAGMENT) && !page.url().startsWith("devtools://")) { - return page; + matches.push(page); } } } - return null; + return matches; } async function poll(attempt) { - const page = findMatchingPage(); - if (page) return page; + const pages = findMatchingPages(); + if (pages.length > 1) { + throw new Error( + `Expected one Electron QA page for ${APP_URL_FRAGMENT}, found ${pages.length}`, + ); + } + if (pages.length === 1) return pages[0]; if (attempt >= 29) { throw new Error(`Unable to find Electron app page for ${APP_URL_FRAGMENT}`); } @@ -412,24 +541,46 @@ async function navigateToSettings(page, serverId) { await page.evaluate((nextServerId) => { window.location.href = `/h/${nextServerId}/settings`; }, serverId); - await page.waitForURL(new RegExp(`/h/${escapeRegExp(serverId)}/settings$`), { - timeout: 30_000, - }); - await page.getByText("Daemon management", { exact: true }).waitFor({ - timeout: 30_000, - }); + await page.getByTestId("settings-sidebar").waitFor({ state: "visible", timeout: 30_000 }); + await page + .getByTestId("settings-detail-header-title") + .waitFor({ state: "visible", timeout: 30_000 }); } -async function dismissMobileSidebarIfVisible(page) { +async function dismissOuterAppSidebarIfVisible(page) { const sidebarSettingsButton = page.locator('[data-testid="sidebar-settings"]').first(); const menuToggle = page.locator('[data-testid="menu-button"]').first(); const bothVisible = (await sidebarSettingsButton.isVisible().catch(() => false)) && (await menuToggle.isVisible().catch(() => false)); - if (!bothVisible) return; + if (!bothVisible) return false; await menuToggle.click(); - await sidebarSettingsButton.waitFor({ state: "hidden", timeout: 10_000 }).catch(() => undefined); + await sidebarSettingsButton.waitFor({ state: "hidden", timeout: 10_000 }); await page.waitForTimeout(500); + return true; +} + +async function restoreOuterAppSidebar(page, wasDismissed) { + if (!wasDismissed) return; + const menuToggle = page.locator('[data-testid="menu-button"]').first(); + const sidebarSettingsButton = page.locator('[data-testid="sidebar-settings"]').first(); + await menuToggle.click(); + await sidebarSettingsButton.waitFor({ state: "visible", timeout: 10_000 }); +} + +async function clearTitlebarAnnotations(page) { + await page.evaluate(() => { + document.getElementById("electron-verify-titlebar-style")?.remove(); + for (const attribute of [ + "data-electron-verify-drag", + "data-electron-verify-resizer", + "data-electron-verify-interactive", + ]) { + for (const element of document.querySelectorAll(`[${attribute}]`)) { + element.removeAttribute(attribute); + } + } + }); } function evaluateDragRegionCheck(dragRegionCheck) { @@ -444,13 +595,13 @@ function evaluateDragRegionCheck(dragRegionCheck) { ); } -function evaluateTrafficLightPadding(dragRegionCheck) { - if (process.platform !== "darwin") return true; - const observedPaddingLeft = dragRegionCheck.candidate?.parent?.paddingLeft ?? null; - return ( - typeof observedPaddingLeft === "number" && - observedPaddingLeft >= 78 && - observedPaddingLeft <= 110 +function evaluateTrafficLightAvoidance(dragRegionCheck) { + const firstInteractive = dragRegionCheck.candidate?.explicitNoDragInteractive?.find( + (entry) => entry.testId === "settings-back-to-workspace", + ); + return Boolean( + firstInteractive && + !rectsIntersect(firstInteractive, { left: 0, top: 0, width: 78, height: 45 }), ); } @@ -462,14 +613,22 @@ async function collectDragRegionResults(page, dragRegionCheck, dragScreenshot, r screenshot: dragScreenshot, }); - const trafficLightScreenshot = await captureScreenshot(page, "04-traffic-light-padding.png"); + const trafficLightScreenshot = await captureScreenshot(page, "04-traffic-light-avoidance.png"); + const firstInteractive = dragRegionCheck.candidate?.explicitNoDragInteractive?.find( + (entry) => entry.testId === "settings-back-to-workspace", + ); results.push({ - check: "traffic-light-padding", - pass: evaluateTrafficLightPadding(dragRegionCheck), + check: "traffic-light-avoidance", + pass: process.platform === "darwin" ? evaluateTrafficLightAvoidance(dragRegionCheck) : true, + skipped: process.platform !== "darwin", details: { platform: process.platform, - observedPaddingLeft: dragRegionCheck.candidate?.parent?.paddingLeft ?? null, - note: "Traffic-light padding is only validated structurally on macOS in this verifier.", + obstruction: process.platform === "darwin" ? { width: 78, height: 45 } : null, + firstInteractive: firstInteractive ?? null, + note: + process.platform === "darwin" + ? "The first interactive sidebar row must not intersect the traffic-light rectangle." + : "Skipped here; the half-screen check exercises the right-side obstruction on Windows/Linux.", candidate: dragRegionCheck.candidate, }, screenshot: trafficLightScreenshot, @@ -489,25 +648,28 @@ async function collectDragRegionResults(page, dragRegionCheck, dragScreenshot, r }); } -async function collectDaemonManagementResult(page, serverId, desktopStatus, results) { - const daemonManagementVisible = await Promise.all([ - page.getByText("Built-in daemon", { exact: true }).isVisible(), - page.getByText("Daemon management", { exact: true }).isVisible(), - page.getByRole("button", { name: "Restart daemon" }).first().isVisible(), - ]).then((values) => values.every(Boolean)); - const daemonManagementScreenshot = await captureScreenshot( - page, - "05-settings-daemon-management.png", - ); +async function collectSettingsSplitResult(page, serverId, desktopStatus, results) { + const geometry = await inspectSettingsGeometry(page); + const sidebarRight = geometry.sidebarRect + ? geometry.sidebarRect.left + geometry.sidebarRect.width + : null; + const settingsScreenshot = await captureScreenshot(page, "05-settings-split.png"); results.push({ - check: "settings-daemon-management", - pass: daemonManagementVisible, + check: "settings-split", + pass: Boolean( + geometry.sidebarRect && + geometry.detailPaneRect && + geometry.detailTitleRect && + sidebarRight !== null && + Math.abs(sidebarRight - geometry.detailPaneRect.left) <= 1, + ), details: { route: page.url(), serverId, desktopStatus, + geometry, }, - screenshot: daemonManagementScreenshot, + screenshot: settingsScreenshot, }); } @@ -515,77 +677,108 @@ async function main() { await ensureDir(OUTPUT_DIR); const browser = await chromium.connectOverCDP(CDP_URL); - const page = await findAppPage(browser); - const consoleMessages = []; - const results = []; + let page = null; + let initialPageUrl = null; + let outerSidebarDismissed = false; - attachConsoleCollector(page, consoleMessages); - await navigateToWelcome(page); + try { + page = await findAppPage(browser); + initialPageUrl = page.url(); + const consoleMessages = []; + const results = []; - const welcomeScreenshot = await captureScreenshot(page, "01-welcome.png"); - const desktopDetection = await detectDesktopBridge(page); + attachConsoleCollector(page, consoleMessages); + await navigateToWelcome(page); - const hasExpectedDesktopShape = - desktopDetection.exists && - REQUIRED_DESKTOP_KEYS.every((key) => desktopDetection.keys.includes(key)); + const welcomeScreenshot = await captureScreenshot(page, "01-welcome.png"); + const desktopDetection = await detectDesktopBridge(page); - results.push({ - check: "desktop-detection", - pass: hasExpectedDesktopShape, - details: desktopDetection, - screenshot: welcomeScreenshot, - }); + const hasExpectedDesktopShape = + desktopDetection.exists && + REQUIRED_DESKTOP_KEYS.every((key) => desktopDetection.keys.includes(key)); + assert( + ["darwin", "win32", "linux"].includes(desktopDetection.platform), + `Unexpected Electron platform: ${desktopDetection.platform}`, + ); - const desktopStatus = await page.evaluate(() => - window.paseoDesktop.invoke("desktop_daemon_status"), - ); - assert( - typeof desktopStatus?.serverId === "string" && desktopStatus.serverId.trim().length > 0, - "desktop_daemon_status did not return a serverId", - ); + results.push({ + check: "desktop-detection", + pass: hasExpectedDesktopShape, + details: desktopDetection, + screenshot: welcomeScreenshot, + }); - const serverId = desktopStatus.serverId.trim(); - await navigateToSettings(page, serverId); + const desktopStatus = await page.evaluate(() => + window.paseoDesktop.invoke("desktop_daemon_status"), + ); + assert( + typeof desktopStatus?.serverId === "string" && desktopStatus.serverId.trim().length > 0, + "desktop_daemon_status did not return a serverId", + ); - await captureScreenshot(page, "02-settings-page.png"); - await dismissMobileSidebarIfVisible(page); + const serverId = desktopStatus.serverId.trim(); + await navigateToSettings(page, serverId); - const dragRegionCheck = await inspectTitlebarRegions(page); - const dragScreenshot = await captureScreenshot(page, "03-drag-region.png"); - await collectDragRegionResults(page, dragRegionCheck, dragScreenshot, results); + await captureScreenshot(page, "02-settings-page.png"); + outerSidebarDismissed = await dismissOuterAppSidebarIfVisible(page); - const fullscreenDetails = await inspectFullscreenResizer(page); - const fullscreenScreenshot = await captureScreenshot(page, "04-fullscreen-resizer.png"); - results.push({ - check: "fullscreen-resizer", - pass: fullscreenDetails.supported ? fullscreenDetails.passed : true, - details: fullscreenDetails, - screenshot: fullscreenScreenshot, - }); + const dragRegionCheck = await inspectTitlebarRegions(page); + const dragScreenshot = await captureScreenshot(page, "03-drag-region.png"); + await collectDragRegionResults(page, dragRegionCheck, dragScreenshot, results); - await collectDaemonManagementResult(page, serverId, desktopStatus, results); + const fullscreenDetails = await inspectFullscreenWindowChrome(page, desktopDetection.platform); + results.push({ + check: "fullscreen-window-chrome", + pass: fullscreenDetails.supported && fullscreenDetails.passed, + details: fullscreenDetails, + screenshot: fullscreenDetails.screenshot ?? null, + }); - const desktopDetectionScreenshot = await captureScreenshot(page, "06-desktop-detection.png"); - results[0].screenshot = desktopDetectionScreenshot; + await collectSettingsSplitResult(page, serverId, desktopStatus, results); - const report = { - cdpUrl: CDP_URL, - outputDir: OUTPUT_DIR, - pageUrl: page.url(), - desktopStatus, - results, - consoleMessages, - }; + if (outerSidebarDismissed) { + await restoreOuterAppSidebar(page, outerSidebarDismissed); + outerSidebarDismissed = false; + } - const reportPath = path.join(OUTPUT_DIR, "report.json"); - await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + const halfScreenDetails = await inspectHalfScreenSettingsLayout( + page, + desktopDetection.platform, + ); + results.push({ + check: "half-screen-settings-layout", + pass: halfScreenDetails.supported && halfScreenDetails.passed, + details: halfScreenDetails, + screenshot: halfScreenDetails.screenshot ?? null, + }); - const failedChecks = results.filter((result) => !result.pass); - console.log(JSON.stringify(report, null, 2)); - await browser.close(); + const desktopDetectionScreenshot = await captureScreenshot(page, "07-desktop-detection.png"); + results[0].screenshot = desktopDetectionScreenshot; - if (failedChecks.length > 0) { - process.exitCode = 1; + const report = { + cdpUrl: CDP_URL, + outputDir: OUTPUT_DIR, + pageUrl: page.url(), + desktopStatus, + results, + consoleMessages, + }; + + const reportPath = path.join(OUTPUT_DIR, "report.json"); + await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + + const failedChecks = results.filter((result) => !result.pass); + console.log(JSON.stringify(report, null, 2)); + if (failedChecks.length > 0) process.exitCode = 1; + } finally { + if (page && !page.isClosed()) { + await clearTitlebarAnnotations(page); + await restoreOuterAppSidebar(page, outerSidebarDismissed); + if (initialPageUrl && page.url() !== initialPageUrl) { + await page.goto(initialPageUrl, { waitUntil: "domcontentloaded" }); + } + } + await browser.close(); } } diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index 1ce664be2..e109b9186 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -24,6 +24,8 @@ contextBridge.exposeInMainWorld("paseoDesktop", { ipcRenderer.invoke("paseo:window:openNew", options), getCurrentWindow: () => ({ toggleMaximize: () => ipcRenderer.invoke("paseo:window:toggleMaximize"), + setFullscreen: (fullscreen: boolean) => + ipcRenderer.invoke("paseo:window:setFullscreen", fullscreen), isFullscreen: () => ipcRenderer.invoke("paseo:window:isFullscreen"), updateWindowControls: (update: { height?: number; diff --git a/packages/desktop/src/window/window-manager.ts b/packages/desktop/src/window/window-manager.ts index a56bb51c1..19aaab22a 100644 --- a/packages/desktop/src/window/window-manager.ts +++ b/packages/desktop/src/window/window-manager.ts @@ -194,6 +194,11 @@ export function registerWindowManager(): void { return win?.isFullScreen() ?? false; }); + ipcMain.handle("paseo:window:setFullscreen", (event, fullscreen: unknown) => { + if (typeof fullscreen !== "boolean") return; + BrowserWindow.fromWebContents(event.sender)?.setFullScreen(fullscreen); + }); + ipcMain.handle("paseo:window:setBadgeCount", (_event, count?: unknown) => { if (process.platform === "darwin" || process.platform === "linux") { const badgeCount = readBadgeCount(count);