From cafff08a30bb258fd46f0d05d284f7a27eee4669 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 1 Apr 2026 17:36:26 +0700 Subject: [PATCH] fix: rewrite titlebar drag to match VS Code's static pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the stateful TitlebarDragRegion (hooks, ResizeObserver, IPC, fullscreen tracking) with a pure static component — matching VS Code's titlebar-drag-region exactly: position absolute, full size, no z-index, no pointer-events, no state, no event listeners. Remove TitlebarNoDragContent entirely — VS Code doesn't wrap content in no-drag; interactive elements get no-drag from the global CSS backstop in index.html. Add drag regions to all header surfaces: - ScreenHeader (sessions, workspace header) - Left sidebar (traffic light area + header) - Split container pane tabs - Explorer sidebar header (Changes/Files tabs) Fix workspace header empty space not draggable by changing headerTitleContainer from flex: 1 to flexShrink: 1. --- .../desktop/titlebar-drag-region.tsx | 223 ++---------------- .../app/src/components/explorer-sidebar.tsx | 3 + .../src/components/headers/screen-header.tsx | 8 +- packages/app/src/components/left-sidebar.tsx | 14 +- .../app/src/components/split-container.tsx | 3 + .../src/screens/agent/draft-agent-screen.tsx | 6 +- .../app/src/screens/open-project-screen.tsx | 36 ++- .../app/src/screens/startup-splash-screen.tsx | 44 ++-- .../screens/workspace/workspace-screen.tsx | 2 +- 9 files changed, 82 insertions(+), 257 deletions(-) diff --git a/packages/app/src/components/desktop/titlebar-drag-region.tsx b/packages/app/src/components/desktop/titlebar-drag-region.tsx index d930f7ab3..d51498a4b 100644 --- a/packages/app/src/components/desktop/titlebar-drag-region.tsx +++ b/packages/app/src/components/desktop/titlebar-drag-region.tsx @@ -1,228 +1,57 @@ -import type { ReactNode } from "react"; -import { useEffect, useRef, useState } from "react"; import { Platform } from "react-native"; import { getIsElectronRuntime } from "@/constants/layout"; -import { getDesktopWindow, updateDesktopWindowControls } from "@/desktop/electron/window"; - -const DEFAULT_OVERLAY_HEIGHT = 29; -const MAX_EFFECTIVE_TITLEBAR_HEIGHT = 48; /** - * VS Code-style titlebar drag region for Electron on Windows/Linux. + * VS Code-style titlebar drag region for Electron. * - * Architecture copied from VS Code at commit daa0a70: - * - titlebarPart.ts:463-464 → dedicated absolute drag overlay element - * - titlebarpart.css:57-64 → drag region styles - * - titlebarpart.css:249-260 → top-edge resizer with no-drag, hidden in fullscreen - * - electron-browser/titlebarPart.ts:205-206 → resizer appended to root container + * Copied from VS Code at commit daa0a70: + * - titlebarPart.ts:463-464 → prepend(container, $('div.titlebar-drag-region')) + * - titlebarpart.css:57-64 → position: absolute, full size, -webkit-app-region: drag + * - titlebarpart.css:249-260 → top-edge resizer, no-drag, 4px * - * This component renders two elements: - * 1. A full-size absolute drag overlay with -webkit-app-region: drag - * 2. A 4px top-edge resizer with -webkit-app-region: no-drag (hidden in fullscreen) + * VS Code's drag region is a static DOM element — no z-index, no pointer-events, + * no state, no event listeners. Interactive elements get no-drag from their own + * CSS (global backstop in index.html). The drag region never re-renders. * - * Both are positioned absolute within the parent container. Interactive children - * of the parent sit above the drag overlay via normal stacking order. - * The global no-drag reset in index.html remains only as a defensive backstop. + * The resizer is Windows/Linux only (titlebarpart.css:249 scopes to .windows/.linux). + * On macOS, Electron handles edge resize natively. */ -function useIsFullscreen(): boolean { - const [isFullscreen, setIsFullscreen] = useState(false); - - useEffect(() => { - if (Platform.OS !== "web" || !getIsElectronRuntime()) return; - - let disposed = false; - let cleanup: (() => void) | undefined; - - async function setup() { - const win = getDesktopWindow(); - if (!win) return; - - const fs = typeof win.isFullscreen === "function" ? await win.isFullscreen() : false; - if (disposed) return; - setIsFullscreen(fs); - - if (typeof win.onResized !== "function") return; - - const unlisten = await win.onResized(async () => { - if (disposed) return; - const fullscreen = typeof win.isFullscreen === "function" ? await win.isFullscreen() : false; - if (disposed) return; - setIsFullscreen(fullscreen); - }); - - cleanup = unlisten; - if (disposed) cleanup?.(); - } - - void setup(); - return () => { - disposed = true; - cleanup?.(); - }; - }, []); - - return isFullscreen; -} - /** - * Renders the VS Code-style drag overlay and top-edge resizer. - * Only renders on Electron Windows/Linux (non-mac). Returns null otherwise. - * - * Usage: Place as a child of any positioned container that should be draggable. - * Interactive elements in the same container will sit above the drag overlay - * via normal DOM stacking order (later siblings paint over earlier ones). - * Place TitlebarDragRegion BEFORE interactive children in JSX. + * Static drag overlay and top-edge resizer. Returns null on non-Electron. + * Place as FIRST child of any positioned container that should be draggable. */ export function TitlebarDragRegion() { if (Platform.OS !== "web" || !getIsElectronRuntime()) { return null; } - return ; -} - -/** - * Explicit no-drag content layer above the drag overlay. - * Matches VS Code's pattern of z-index: 2500 + -webkit-app-region: no-drag - * on interactive containers within the titlebar (titlebarpart.css:141-143, - * 231-233, 264-268, 288-298, 382-401). - * - * Wrap interactive children of any TitlebarDragRegion parent in this component - * so the no-drag layering is structural, not dependent on the global CSS backstop. - * On non-Electron or mac, renders children directly with no wrapper. - */ -interface TitlebarNoDragContentProps { - children: ReactNode; - /** Layout direction for the no-drag wrapper. Default: "row" for header-style surfaces. */ - direction?: "row" | "column"; -} - -export function TitlebarNoDragContent({ children, direction = "row" }: TitlebarNoDragContentProps) { - if (Platform.OS !== "web" || !getIsElectronRuntime()) { - return <>{children}; - } - - return ( -
- {children} -
- ); -} - -function TitlebarDragRegionInner() { - const isFullscreen = useIsFullscreen(); - const dragRegionRef = useRef(null); - const lastReportedHeightRef = useRef(null); - - useEffect(() => { - const dragRegion = dragRegionRef.current; - const parent = dragRegion?.parentElement; - if (!dragRegion || !parent) { - return; - } - - let disposed = false; - let frameId: number | null = null; - - const reportHeight = () => { - if (disposed) { - return; - } - - const nextHeight = Math.min( - MAX_EFFECTIVE_TITLEBAR_HEIGHT, - Math.max(DEFAULT_OVERLAY_HEIGHT, Math.round(parent.getBoundingClientRect().height)), - ); - - if (lastReportedHeightRef.current === nextHeight) { - return; - } - - lastReportedHeightRef.current = nextHeight; - void updateDesktopWindowControls({ height: nextHeight }).catch((error) => { - console.warn("[DesktopWindow] Failed to update window controls height", error); - }); - }; - - const scheduleReport = () => { - if (frameId !== null) { - cancelAnimationFrame(frameId); - } - frameId = requestAnimationFrame(() => { - frameId = null; - reportHeight(); - }); - }; - - scheduleReport(); - - const resizeObserver = - typeof ResizeObserver === "function" - ? new ResizeObserver(() => { - scheduleReport(); - }) - : null; - - resizeObserver?.observe(parent); - window.addEventListener("resize", scheduleReport); - - return () => { - disposed = true; - if (frameId !== null) { - cancelAnimationFrame(frameId); - } - resizeObserver?.disconnect(); - window.removeEventListener("resize", scheduleReport); - }; - }, []); - return ( <> - {/* Drag overlay — equivalent to VS Code .titlebar-drag-region */} + {/* Drag overlay — VS Code .titlebar-drag-region (titlebarpart.css:57-64) */}
- {/* Top-edge resizer — equivalent to VS Code .resizer */} - {!isFullscreen && ( -
- )} + {/* Top-edge resizer — VS Code .resizer (titlebarpart.css:249-256) */} +
); } diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 9656aead9..5d22549c5 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -18,6 +18,7 @@ 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 { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; const MIN_CHAT_WIDTH = 400; function logExplorerSidebar(_event: string, _details: Record): void {} @@ -344,6 +345,7 @@ function SidebarContent({ {/* Header with tabs and close button */} + {isGit && ( ({ overflow: "hidden", }, header: { + position: "relative", height: HEADER_INNER_HEIGHT, flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/components/headers/screen-header.tsx b/packages/app/src/components/headers/screen-header.tsx index 9aa7c2e0e..a544efdab 100644 --- a/packages/app/src/components/headers/screen-header.tsx +++ b/packages/app/src/components/headers/screen-header.tsx @@ -9,7 +9,7 @@ import { isCompactFormFactor, } from "@/constants/layout"; import { useWindowControlsPadding } from "@/utils/desktop-window"; -import { TitlebarDragRegion, TitlebarNoDragContent } from "@/components/desktop/titlebar-drag-region"; +import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; interface ScreenHeaderProps { left?: ReactNode; @@ -46,10 +46,8 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }: ]} > - - {left} - {right} - + {left} + {right} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index e62e6085e..ca7377e44 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -36,7 +36,7 @@ import { } from "@/hooks/use-sidebar-workspaces-list"; import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; import { useWindowControlsPadding } from "@/utils/desktop-window"; -import { TitlebarDragRegion, TitlebarNoDragContent } from "@/components/desktop/titlebar-drag-region"; +import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { Combobox } from "@/components/ui/combobox"; import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; import { formatConnectionStatus } from "@/utils/daemons"; @@ -689,14 +689,14 @@ function DesktopSidebar({ return ( - {padding.top > 0 ? : null} - + - + {padding.top > 0 ? : null} + - + {isInitialLoad ? ( @@ -834,8 +834,10 @@ const styles = StyleSheet.create((theme) => ({ width: 10, zIndex: 10, }, - sidebarHeader: { + sidebarDragArea: { position: "relative", + }, + sidebarHeader: { height: { xs: HEADER_INNER_HEIGHT_MOBILE, md: HEADER_INNER_HEIGHT, diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index b7c192f78..ce42f2d76 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -33,6 +33,7 @@ import { ResizeHandle } from "@/components/resize-handle"; import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus"; import { usePanelStore } from "@/stores/panel-store"; import { useWindowControlsPadding } from "@/utils/desktop-window"; +import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { computeTabDropPreview, type TabDropPreview, @@ -887,6 +888,7 @@ function SplitPaneView({ { paddingLeft: padding.left, paddingRight: padding.right }, ]} > + ({ overflow: "hidden", }, paneTabs: { + position: "relative", minWidth: 0, }, paneContent: { diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index d2bd175c0..7acd471c1 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -51,7 +51,7 @@ import type { } from "@server/server/agent/agent-sdk-types"; import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; -import { TitlebarDragRegion, TitlebarNoDragContent } from "@/components/desktop/titlebar-drag-region"; +import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; @@ -1000,8 +1000,7 @@ function DraftAgentScreenContent({ const mainContent = ( - - + ) : null} - ); diff --git a/packages/app/src/screens/open-project-screen.tsx b/packages/app/src/screens/open-project-screen.tsx index ebada269e..073301177 100644 --- a/packages/app/src/screens/open-project-screen.tsx +++ b/packages/app/src/screens/open-project-screen.tsx @@ -9,7 +9,7 @@ import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { usePanelStore } from "@/stores/panel-store"; import { useSessionStore } from "@/stores/session-store"; import { isCompactFormFactor } from "@/constants/layout"; -import { TitlebarDragRegion, TitlebarNoDragContent } from "@/components/desktop/titlebar-drag-region"; +import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; export function OpenProjectScreen({ serverId }: { serverId: string }) { const openAgentList = usePanelStore((s) => s.openAgentList); @@ -30,24 +30,22 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) { - - - - - - What shall we build today? - {hasHydrated && !hasProjects ? ( - - Add a project folder to start running agents on your codebase - - ) : null} - - - - - + + + + + What shall we build today? + {hasHydrated && !hasProjects ? ( + + Add a project folder to start running agents on your codebase + + ) : null} + + + + ); diff --git a/packages/app/src/screens/startup-splash-screen.tsx b/packages/app/src/screens/startup-splash-screen.tsx index 511205aa1..188fb9e9c 100644 --- a/packages/app/src/screens/startup-splash-screen.tsx +++ b/packages/app/src/screens/startup-splash-screen.tsx @@ -11,7 +11,7 @@ import { getDesktopDaemonLogs, type DesktopDaemonLogs, } from "@/desktop/daemon/desktop-daemon"; -import { TitlebarDragRegion, TitlebarNoDragContent } from "@/components/desktop/titlebar-drag-region"; +import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; type StartupSplashScreenProps = { bootstrapState?: { @@ -224,10 +224,8 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps return ( - - - Starting up… - + + Starting up… ); } @@ -236,24 +234,22 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps return ( - - - - Welcome to Paseo - - {progressSteps.map((step) => ( - - {step.status === "complete" ? ( - - ) : ( - - )} - {step.label} - - ))} - + + + Welcome to Paseo + + {progressSteps.map((step) => ( + + {step.status === "complete" ? ( + + ) : ( + + )} + {step.label} + + ))} - + ); } @@ -261,8 +257,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps return ( - - + Something went wrong @@ -321,7 +316,6 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps - ); } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index e73eadb02..ef072fdae 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -2327,7 +2327,7 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 1, }, headerTitleContainer: { - flex: 1, + flexShrink: 1, minWidth: 0, flexDirection: "row", alignItems: "center",