fix: rewrite titlebar drag to match VS Code's static pattern

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.
This commit is contained in:
Mohamed Boudra
2026-04-01 17:36:26 +07:00
parent cb60f2a596
commit cafff08a30
9 changed files with 82 additions and 257 deletions
@@ -1,228 +1,57 @@
import type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { getIsElectronRuntime } from "@/constants/layout"; 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: * Copied from VS Code at commit daa0a70:
* - titlebarPart.ts:463-464 → dedicated absolute drag overlay element * - titlebarPart.ts:463-464 → prepend(container, $('div.titlebar-drag-region'))
* - titlebarpart.css:57-64 → drag region styles * - titlebarpart.css:57-64 → position: absolute, full size, -webkit-app-region: drag
* - titlebarpart.css:249-260 → top-edge resizer with no-drag, hidden in fullscreen * - titlebarpart.css:249-260 → top-edge resizer, no-drag, 4px
* - electron-browser/titlebarPart.ts:205-206 → resizer appended to root container
* *
* This component renders two elements: * VS Code's drag region is a static DOM element — no z-index, no pointer-events,
* 1. A full-size absolute drag overlay with -webkit-app-region: drag * no state, no event listeners. Interactive elements get no-drag from their own
* 2. A 4px top-edge resizer with -webkit-app-region: no-drag (hidden in fullscreen) * CSS (global backstop in index.html). The drag region never re-renders.
* *
* Both are positioned absolute within the parent container. Interactive children * The resizer is Windows/Linux only (titlebarpart.css:249 scopes to .windows/.linux).
* of the parent sit above the drag overlay via normal stacking order. * On macOS, Electron handles edge resize natively.
* The global no-drag reset in index.html remains only as a defensive backstop.
*/ */
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. * Static drag overlay and top-edge resizer. Returns null on non-Electron.
* Only renders on Electron Windows/Linux (non-mac). Returns null otherwise. * Place as FIRST child of any positioned container that should be draggable.
*
* 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.
*/ */
export function TitlebarDragRegion() { export function TitlebarDragRegion() {
if (Platform.OS !== "web" || !getIsElectronRuntime()) { if (Platform.OS !== "web" || !getIsElectronRuntime()) {
return null; return null;
} }
return <TitlebarDragRegionInner />;
}
/**
* 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 (
<div
style={{
position: "relative",
zIndex: 2500,
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
WebkitAppRegion: "no-drag",
display: "flex",
flexDirection: direction,
alignItems: "center",
justifyContent: direction === "column" ? "center" : undefined,
flex: 1,
height: "100%",
minWidth: 0,
}}
>
{children}
</div>
);
}
function TitlebarDragRegionInner() {
const isFullscreen = useIsFullscreen();
const dragRegionRef = useRef<HTMLDivElement | null>(null);
const lastReportedHeightRef = useRef<number | null>(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 ( return (
<> <>
{/* Drag overlay — equivalent to VS Code .titlebar-drag-region */} {/* Drag overlay — VS Code .titlebar-drag-region (titlebarpart.css:57-64) */}
<div <div
ref={dragRegionRef}
style={{ style={{
position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
display: "block",
position: "absolute",
width: "100%", width: "100%",
height: "100%", height: "100%",
// @ts-expect-error — WebkitAppRegion is not in CSSProperties // @ts-expect-error — WebkitAppRegion is not in CSSProperties
WebkitAppRegion: "drag", WebkitAppRegion: "drag",
zIndex: 0,
pointerEvents: "auto",
}} }}
/> />
{/* Top-edge resizer — equivalent to VS Code .resizer */} {/* Top-edge resizer — VS Code .resizer (titlebarpart.css:249-256) */}
{!isFullscreen && ( <div
<div style={{
style={{ position: "absolute",
position: "absolute", top: 0,
top: 0, width: "100%",
left: 0, height: 4,
width: "100%", // @ts-expect-error — WebkitAppRegion is not in CSSProperties
height: 4, WebkitAppRegion: "no-drag",
// @ts-expect-error — WebkitAppRegion is not in CSSProperties }}
WebkitAppRegion: "no-drag", />
zIndex: 1,
}}
/>
)}
</> </>
); );
} }
@@ -18,6 +18,7 @@ import { GitDiffPane } from "./git-diff-pane";
import { FileExplorerPane } from "./file-explorer-pane"; import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useWindowControlsPadding } from "@/utils/desktop-window"; import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
const MIN_CHAT_WIDTH = 400; const MIN_CHAT_WIDTH = 400;
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {} function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
@@ -344,6 +345,7 @@ function SidebarContent({
<View style={styles.sidebarContent} pointerEvents="auto"> <View style={styles.sidebarContent} pointerEvents="auto">
{/* Header with tabs and close button */} {/* Header with tabs and close button */}
<View style={[styles.header, { paddingRight: padding.right }]} testID="explorer-header"> <View style={[styles.header, { paddingRight: padding.right }]} testID="explorer-header">
<TitlebarDragRegion />
<View style={styles.tabsContainer}> <View style={styles.tabsContainer}>
{isGit && ( {isGit && (
<Pressable <Pressable
@@ -434,6 +436,7 @@ const styles = StyleSheet.create((theme) => ({
overflow: "hidden", overflow: "hidden",
}, },
header: { header: {
position: "relative",
height: HEADER_INNER_HEIGHT, height: HEADER_INNER_HEIGHT,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -9,7 +9,7 @@ import {
isCompactFormFactor, isCompactFormFactor,
} from "@/constants/layout"; } from "@/constants/layout";
import { useWindowControlsPadding } from "@/utils/desktop-window"; 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 { interface ScreenHeaderProps {
left?: ReactNode; left?: ReactNode;
@@ -46,10 +46,8 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }:
]} ]}
> >
<TitlebarDragRegion /> <TitlebarDragRegion />
<TitlebarNoDragContent> <View style={[styles.left, leftStyle]}>{left}</View>
<View style={[styles.left, leftStyle]}>{left}</View> <View style={[styles.right, rightStyle]}>{right}</View>
<View style={[styles.right, rightStyle]}>{right}</View>
</TitlebarNoDragContent>
</View> </View>
</View> </View>
</View> </View>
+8 -6
View File
@@ -36,7 +36,7 @@ import {
} from "@/hooks/use-sidebar-workspaces-list"; } from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useWindowControlsPadding } from "@/utils/desktop-window"; 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 { Combobox } from "@/components/ui/combobox";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { formatConnectionStatus } from "@/utils/daemons"; import { formatConnectionStatus } from "@/utils/daemons";
@@ -689,14 +689,14 @@ function DesktopSidebar({
return ( return (
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }]}> <Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }]}>
{padding.top > 0 ? <View style={{ height: padding.top }} /> : null} <View style={styles.sidebarDragArea}>
<View style={styles.sidebarHeader}>
<TitlebarDragRegion /> <TitlebarDragRegion />
<TitlebarNoDragContent> {padding.top > 0 ? <View style={{ height: padding.top }} /> : null}
<View style={styles.sidebarHeader}>
<View style={styles.sidebarHeaderRow}> <View style={styles.sidebarHeaderRow}>
<SessionsButton onPress={handleViewMore} /> <SessionsButton onPress={handleViewMore} />
</View> </View>
</TitlebarNoDragContent> </View>
</View> </View>
{isInitialLoad ? ( {isInitialLoad ? (
@@ -834,8 +834,10 @@ const styles = StyleSheet.create((theme) => ({
width: 10, width: 10,
zIndex: 10, zIndex: 10,
}, },
sidebarHeader: { sidebarDragArea: {
position: "relative", position: "relative",
},
sidebarHeader: {
height: { height: {
xs: HEADER_INNER_HEIGHT_MOBILE, xs: HEADER_INNER_HEIGHT_MOBILE,
md: HEADER_INNER_HEIGHT, md: HEADER_INNER_HEIGHT,
@@ -33,6 +33,7 @@ import { ResizeHandle } from "@/components/resize-handle";
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus"; import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { useWindowControlsPadding } from "@/utils/desktop-window"; import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { import {
computeTabDropPreview, computeTabDropPreview,
type TabDropPreview, type TabDropPreview,
@@ -887,6 +888,7 @@ function SplitPaneView({
{ paddingLeft: padding.left, paddingRight: padding.right }, { paddingLeft: padding.left, paddingRight: padding.right },
]} ]}
> >
<TitlebarDragRegion />
<WorkspaceDesktopTabsRow <WorkspaceDesktopTabsRow
paneId={pane.id} paneId={pane.id}
isFocused={isFocused} isFocused={isFocused}
@@ -997,6 +999,7 @@ const styles = StyleSheet.create((theme) => ({
overflow: "hidden", overflow: "hidden",
}, },
paneTabs: { paneTabs: {
position: "relative",
minWidth: 0, minWidth: 0,
}, },
paneContent: { paneContent: {
@@ -51,7 +51,7 @@ import type {
} from "@server/server/agent/agent-sdk-types"; } from "@server/server/agent/agent-sdk-types";
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest"; import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; 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 { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
@@ -1000,8 +1000,7 @@ function DraftAgentScreenContent({
const mainContent = ( const mainContent = (
<View style={styles.container}> <View style={styles.container}>
<TitlebarDragRegion /> <TitlebarDragRegion />
<TitlebarNoDragContent direction="column"> <View style={styles.outerContainer}>
<View style={styles.outerContainer}>
<View style={styles.agentPanel}> <View style={styles.agentPanel}>
<View <View
style={[ style={[
@@ -1264,7 +1263,6 @@ function DraftAgentScreenContent({
/> />
) : null} ) : null}
</View> </View>
</TitlebarNoDragContent>
</View> </View>
); );
@@ -9,7 +9,7 @@ import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { isCompactFormFactor } from "@/constants/layout"; 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 }) { export function OpenProjectScreen({ serverId }: { serverId: string }) {
const openAgentList = usePanelStore((s) => s.openAgentList); const openAgentList = usePanelStore((s) => s.openAgentList);
@@ -30,24 +30,22 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
<MenuHeader borderless /> <MenuHeader borderless />
<View style={styles.content}> <View style={styles.content}>
<TitlebarDragRegion /> <TitlebarDragRegion />
<TitlebarNoDragContent direction="column"> <View style={styles.logo}>
<View style={styles.logo}> <PaseoLogo size={56} />
<PaseoLogo size={56} /> </View>
</View> <View style={styles.headingGroup}>
<View style={styles.headingGroup}> <Text style={styles.heading}>What shall we build today?</Text>
<Text style={styles.heading}>What shall we build today?</Text> {hasHydrated && !hasProjects ? (
{hasHydrated && !hasProjects ? ( <Text style={styles.subtitle}>
<Text style={styles.subtitle}> Add a project folder to start running agents on your codebase
Add a project folder to start running agents on your codebase </Text>
</Text> ) : null}
) : null} </View>
</View> <View style={styles.cta}>
<View style={styles.cta}> <Button variant="default" leftIcon={FolderOpen} onPress={() => void openProjectPicker()} testID="open-project-submit">
<Button variant="default" leftIcon={FolderOpen} onPress={() => void openProjectPicker()} testID="open-project-submit"> Add a project
Add a project </Button>
</Button> </View>
</View>
</TitlebarNoDragContent>
</View> </View>
</View> </View>
); );
@@ -11,7 +11,7 @@ import {
getDesktopDaemonLogs, getDesktopDaemonLogs,
type DesktopDaemonLogs, type DesktopDaemonLogs,
} from "@/desktop/daemon/desktop-daemon"; } from "@/desktop/daemon/desktop-daemon";
import { TitlebarDragRegion, TitlebarNoDragContent } from "@/components/desktop/titlebar-drag-region"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
type StartupSplashScreenProps = { type StartupSplashScreenProps = {
bootstrapState?: { bootstrapState?: {
@@ -224,10 +224,8 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
return ( return (
<View style={styles.container}> <View style={styles.container}>
<TitlebarDragRegion /> <TitlebarDragRegion />
<TitlebarNoDragContent direction="column"> <PaseoLogo size={96} />
<PaseoLogo size={96} /> <Text style={styles.subtitle}>Starting up…</Text>
<Text style={styles.subtitle}>Starting up…</Text>
</TitlebarNoDragContent>
</View> </View>
); );
} }
@@ -236,24 +234,22 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
return ( return (
<View style={styles.container}> <View style={styles.container}>
<TitlebarDragRegion /> <TitlebarDragRegion />
<TitlebarNoDragContent direction="column"> <View style={styles.centeredContent}>
<View style={styles.centeredContent}> <PaseoLogo size={96} />
<PaseoLogo size={96} /> <Text style={styles.title}>Welcome to Paseo</Text>
<Text style={styles.title}>Welcome to Paseo</Text> <View style={styles.progressSteps}>
<View style={styles.progressSteps}> {progressSteps.map((step) => (
{progressSteps.map((step) => ( <View key={step.key} style={styles.progressStepRow}>
<View key={step.key} style={styles.progressStepRow}> {step.status === "complete" ? (
{step.status === "complete" ? ( <Check size={18} color={theme.colors.success} />
<Check size={18} color={theme.colors.success} /> ) : (
) : ( <ActivityIndicator color={theme.colors.accent} />
<ActivityIndicator color={theme.colors.accent} /> )}
)} <Text style={styles.subtitleInline}>{step.label}</Text>
<Text style={styles.subtitleInline}>{step.label}</Text> </View>
</View> ))}
))}
</View>
</View> </View>
</TitlebarNoDragContent> </View>
</View> </View>
); );
} }
@@ -261,8 +257,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
return ( return (
<View style={[styles.container, styles.containerError]}> <View style={[styles.container, styles.containerError]}>
<TitlebarDragRegion /> <TitlebarDragRegion />
<TitlebarNoDragContent direction="column"> <View style={styles.errorContent}>
<View style={styles.errorContent}>
<View style={styles.errorHeader}> <View style={styles.errorHeader}>
<PaseoLogo size={64} /> <PaseoLogo size={64} />
<Text style={[styles.title, styles.titleError]}>Something went wrong</Text> <Text style={[styles.title, styles.titleError]}>Something went wrong</Text>
@@ -321,7 +316,6 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
</Button> </Button>
</View> </View>
</View> </View>
</TitlebarNoDragContent>
</View> </View>
); );
} }
@@ -2327,7 +2327,7 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 1, flexShrink: 1,
}, },
headerTitleContainer: { headerTitleContainer: {
flex: 1, flexShrink: 1,
minWidth: 0, minWidth: 0,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",