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);