diff --git a/docs/design.md b/docs/design.md
index 172416984b..00c7335e28 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -131,6 +131,8 @@ The branching is one `useIsCompactFormFactor()` check at the top of the screen c
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`) follows a different but parallel rule: tabs collapse on compact, panes split on desktop. The sidebar (`packages/app/src/components/left-sidebar.tsx`) is overlaid on compact and pinned on desktop.
+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 d435f11f02..b867726800 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/sidebar-workspace.spec.ts b/packages/app/e2e/sidebar-workspace.spec.ts
index ed69402030..c05a0ec4f6 100644
--- a/packages/app/e2e/sidebar-workspace.spec.ts
+++ b/packages/app/e2e/sidebar-workspace.spec.ts
@@ -165,3 +165,13 @@ 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();
+ });
+});
diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index 1c0449fd32..7e17a5e031 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -91,6 +91,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 +410,7 @@ 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 cycleTheme = useCallback(() => {
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
@@ -461,15 +463,26 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
/>
);
+ const desktopSidebarRendered =
+ !isCompactLayout && chromeEnabled && !isFocusModeEnabled && isDesktopAgentListOpen;
+ const contentWindowChromeCorners = desktopSidebarRendered ? "top-right" : "both";
const workspaceChrome = (
- {!isCompactLayout ? sidebarChrome : null}
+ {!isCompactLayout ? (
+
+ {sidebarChrome}
+
+ ) : null}
{isCompactLayout && chromeEnabled ? (
- {children}
+
+ {children}
+
) : (
- {children}
+
+ {children}
+
)}
);
@@ -863,13 +876,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 b44e769284..4732782299 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/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx
index 1d964dc7a6..90a4026e2f 100644
--- a/packages/app/src/components/explorer-sidebar.tsx
+++ b/packages/app/src/components/explorer-sidebar.tsx
@@ -36,7 +36,7 @@ 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";
@@ -300,7 +300,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 +324,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 +375,7 @@ function ExplorerSidebarContent({
)}
-
+
{/* Content based on active tab */}
@@ -476,7 +475,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 ce2ca302f5..b8f078e150 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-width.test.ts b/packages/app/src/components/left-sidebar-width.test.ts
new file mode 100644
index 0000000000..89fb2c0ee5
--- /dev/null
+++ b/packages/app/src/components/left-sidebar-width.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from "vitest";
+import { resolveDesktopSidebarWidth } from "@/components/left-sidebar-width";
+
+describe("desktop sidebar width", () => {
+ it("clamps a persisted wide sidebar to preserve 400px of content", () => {
+ const atHalfScreen = resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 751 });
+ expect(atHalfScreen).toBe(351);
+ expect(751 - atHalfScreen).toBe(400);
+
+ const atBreakpoint = resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 720 });
+ expect(atBreakpoint).toBe(320);
+ expect(720 - atBreakpoint).toBe(400);
+ });
+});
diff --git a/packages/app/src/components/left-sidebar-width.ts b/packages/app/src/components/left-sidebar-width.ts
new file mode 100644
index 0000000000..7d2af07065
--- /dev/null
+++ b/packages/app/src/components/left-sidebar-width.ts
@@ -0,0 +1,15 @@
+import { MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store";
+
+const MIN_CHAT_WIDTH = 400;
+
+export function resolveDesktopSidebarWidth(input: {
+ requestedWidth: number;
+ viewportWidth: number;
+}): number {
+ "worklet";
+ const maxWidth = Math.max(
+ MIN_SIDEBAR_WIDTH,
+ Math.min(MAX_SIDEBAR_WIDTH, input.viewportWidth - MIN_CHAT_WIDTH),
+ );
+ return Math.max(MIN_SIDEBAR_WIDTH, Math.min(maxWidth, input.requestedWidth));
+}
diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx
index db0145b475..2cc4b402b7 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/left-sidebar-width";
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,7 +710,6 @@ 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();
@@ -725,6 +721,14 @@ function DesktopSidebar({
resizeWidth.value = sidebarWidth;
}, [sidebarWidth, resizeWidth]);
+ useEffect(() => {
+ const clampedWidth = resolveDesktopSidebarWidth({
+ requestedWidth: sidebarWidth,
+ viewportWidth,
+ });
+ if (sidebarWidth !== clampedWidth) setSidebarWidth(clampedWidth);
+ }, [setSidebarWidth, sidebarWidth, viewportWidth]);
+
const resizeGesture = useMemo(
() =>
Gesture.Pan()
@@ -736,12 +740,10 @@ function DesktopSidebar({
.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);
@@ -753,7 +755,6 @@ function DesktopSidebar({
width: resizeWidth.value,
}));
- const paddingTopSpacerStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
const desktopSidebarStyle = useMemo(
() => [staticStyles.desktopSidebar, resizeAnimatedStyle],
[resizeAnimatedStyle],
@@ -776,7 +777,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 0000000000..5e67d784af
--- /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 0000000000..e16f4571d1
--- /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 bf90d1f98f..c9e4294f9d 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/desktop/host.ts b/packages/app/src/desktop/host.ts
index f76a2c5281..768552b929 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 2a6cdf9506..ca0987756e 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 b5e2bf16e5..5189e5048d 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";
@@ -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}
diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx
index b35b3a1b4c..1ae7c07786 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 c939a5745b..0000000000
--- 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 0000000000..5bae0df5ec
--- /dev/null
+++ b/packages/app/src/utils/desktop-window.tsx
@@ -0,0 +1,232 @@
+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() {
+ if (!active || dispose || retryTimer || retryCount >= 40) return;
+ retryCount += 1;
+ retryTimer = setTimeout(() => {
+ retryTimer = undefined;
+ connect();
+ }, 250);
+ }
+
+ function connect() {
+ if (!active || dispose || connecting) return;
+ if (!getIsElectronRuntime()) return scheduleRetry();
+ const window = getDesktopWindow();
+ if (
+ !window ||
+ typeof window.isFullscreen !== "function" ||
+ typeof window.onResized !== "function"
+ )
+ return scheduleRetry();
+ const readFullscreen = window.isFullscreen;
+ const subscribeToResized = window.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 eb916638c0..bcbe566cc3 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 0a795c2a7e..871833f663 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,95 @@ 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"]'),
+ 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 +346,149 @@ 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");
+ await sidebar.waitFor({ state: "visible", timeout: 10_000 });
+ await detail.waitFor({ state: "visible", 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 &&
+ 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 +537,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 +591,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 +609,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 +644,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 +673,103 @@ 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,
- };
+ 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 reportPath = path.join(OUTPUT_DIR, "report.json");
- await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
+ const desktopDetectionScreenshot = await captureScreenshot(page, "07-desktop-detection.png");
+ results[0].screenshot = desktopDetectionScreenshot;
- const failedChecks = results.filter((result) => !result.pass);
- console.log(JSON.stringify(report, null, 2));
- await browser.close();
+ const report = {
+ cdpUrl: CDP_URL,
+ outputDir: OUTPUT_DIR,
+ pageUrl: page.url(),
+ desktopStatus,
+ results,
+ consoleMessages,
+ };
- if (failedChecks.length > 0) {
- process.exitCode = 1;
+ 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 1ce664be21..e109b91861 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 a56bb51c14..19aaab22ab 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);