diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 0e20c89d0d2..772484bff8c 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -44,6 +44,7 @@ const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-ma const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs') const { readWindowsUserEnvVar } = require('./windows-user-env.cjs') const { readWslWindowsClipboardImage } = require('./wsl-clipboard-image.cjs') +const { nativeOverlayWidth: computeNativeOverlayWidth } = require('./titlebar-overlay-width.cjs') const { readDirForIpc } = require('./fs-read-dir.cjs') const { readLiveUpdateMarker } = require('./update-marker.cjs') const { @@ -187,6 +188,16 @@ if (REMOTE_DISPLAY_REASON) { ) } +// WSLg: Chromium blocklists the Mesa vGPU → software compositing → typing lag. +// /dev/dxg means a real GPU is available; un-blocklist it. Skipped when a remote +// display already forced software (SSH'd-into-WSL). +if (IS_WSL && !REMOTE_DISPLAY_REASON && fs.existsSync('/dev/dxg')) { + app.commandLine.appendSwitch('ignore-gpu-blocklist') + app.commandLine.appendSwitch('enable-gpu-rasterization') + app.commandLine.appendSwitch('enable-zero-copy') + console.log('[hermes] WSL GPU passthrough (/dev/dxg) detected; enabling GPU acceleration') +} + ipcMain.handle('hermes:get-remote-display-reason', () => REMOTE_DISPLAY_REASON) // Keep the renderer running at full speed while the window is in the background @@ -399,14 +410,10 @@ const WINDOW_BUTTON_POSITION = { x: 24, y: TITLEBAR_HEIGHT / 2 - MACOS_TRAFFIC_LIGHTS_HEIGHT / 2 } -// Width Electron reserves for the Windows/Linux native min/max/close cluster -// when `titleBarOverlay` is enabled. The OS paints these buttons in the -// top-right corner of the renderer; we have to leave that much room on the -// right edge so our system tools (file browser, haptics, settings) don't sit -// underneath them. macOS uses left-side traffic lights instead and reports a -// position via getWindowButtonPosition(), so this width is non-zero only on -// non-macOS platforms. -const NATIVE_OVERLAY_BUTTON_WIDTH = 144 +// Right-edge window-control reservation lives in titlebar-overlay-width.cjs +// (pure + unit-testable); computeNativeOverlayWidth() applies it per platform. +// It's only the pre-layout fallback — the renderer measures the exact overlay +// width live via the Window Controls Overlay API. const APP_ICON_PATHS = [ path.join(APP_ROOT, 'public', 'apple-touch-icon.png'), path.join(APP_ROOT, 'dist', 'apple-touch-icon.png'), @@ -525,11 +532,12 @@ function getTitleBarOverlayOptions() { return { height: TITLEBAR_HEIGHT } } - // Window Controls Overlay is a Windows/macOS-only Electron feature. On Linux - // (including WSLg, where the RDP host draws its own min/max/close) requesting - // it does nothing useful, so disable it and let the frameless window stand on - // its own (titleBarStyle stays 'hidden'). - if (!IS_WINDOWS) { + // Window Controls Overlay: Windows paints it natively, and WSLg honors it too + // (it renders through a real compositor), so keep it enabled there — disabling + // it removes the min/max/close buttons entirely. Only plain (non-WSL) Linux, + // where some WMs/builds don't support WCO, falls through to no overlay; the + // frameless titleBarStyle 'hidden' still applies. + if (!IS_WINDOWS && !IS_WSL) { return false } @@ -551,9 +559,9 @@ function getTitleBarOverlayOptions() { } // Push refreshed overlay options to a live window after a theme/appearance -// change. No-op on Linux (incl. WSLg), where the overlay is unsupported and -// getTitleBarOverlayOptions() returns false — calling setTitleBarOverlay there -// can throw on some Electron Linux builds. +// change. No-op only on plain (non-WSL) Linux, where getTitleBarOverlayOptions() +// returns false; the try/catch additionally guards builds where +// setTitleBarOverlay isn't supported. function applyTitleBarOverlay(win) { const options = getTitleBarOverlayOptions() if (!options || typeof options !== 'object') { @@ -3787,15 +3795,7 @@ function getWindowButtonPosition() { } function getNativeOverlayWidth() { - // Only Windows paints an Electron Window Controls Overlay on the right that - // the renderer must inset its right cluster to clear. - // - macOS reports traffic-light coords via windowButtonPosition; its - // titleBarOverlay doesn't reserve right-edge space. - // - Linux (incl. WSLg) doesn't support titleBarOverlay at all — Electron - // paints no native controls there, so reserving width just leaves a dead - // gap and, under WSLg (where the RDP host draws its own min/max/close), - // pushes the right tools out of alignment with those host buttons. - return IS_WINDOWS ? NATIVE_OVERLAY_BUTTON_WIDTH : 0 + return computeNativeOverlayWidth({ isWindows: IS_WINDOWS, isWsl: IS_WSL }) } function getWindowState() { diff --git a/apps/desktop/electron/titlebar-overlay-width.cjs b/apps/desktop/electron/titlebar-overlay-width.cjs new file mode 100644 index 00000000000..27a62f1a9f3 --- /dev/null +++ b/apps/desktop/electron/titlebar-overlay-width.cjs @@ -0,0 +1,11 @@ +// Pre-layout fallback for WCO right-edge reservation (--titlebar-tools-right). +// Live width comes from navigator.windowControlsOverlay in the renderer. + +const OVERLAY_FALLBACK_WIDTH = 144 + +/** @param {{ isWindows?: boolean, isWsl?: boolean }} opts */ +function nativeOverlayWidth({ isWindows = false, isWsl = false } = {}) { + return isWindows || isWsl ? OVERLAY_FALLBACK_WIDTH : 0 +} + +module.exports = { OVERLAY_FALLBACK_WIDTH, nativeOverlayWidth } diff --git a/apps/desktop/electron/titlebar-overlay-width.test.cjs b/apps/desktop/electron/titlebar-overlay-width.test.cjs new file mode 100644 index 00000000000..a7b8b6f5215 --- /dev/null +++ b/apps/desktop/electron/titlebar-overlay-width.test.cjs @@ -0,0 +1,29 @@ +const assert = require('node:assert/strict') +const test = require('node:test') + +const { OVERLAY_FALLBACK_WIDTH, nativeOverlayWidth } = require('./titlebar-overlay-width.cjs') + +// This static reservation is only the pre-layout FALLBACK. Once laid out the +// renderer reads the exact width from navigator.windowControlsOverlay +// (use-window-controls-overlay-width.ts) and uses these values only when the WCO +// API is unavailable. + +test('Windows reserves the overlay fallback width', () => { + assert.equal(nativeOverlayWidth({ isWindows: true }), OVERLAY_FALLBACK_WIDTH) +}) + +test('WSLg paints the same WCO, so it reserves the same fallback width', () => { + // The original bug: WSL fell through to 0, so the right tools sat under the + // controls and the title overran into them. + assert.equal(nativeOverlayWidth({ isWsl: true }), OVERLAY_FALLBACK_WIDTH) +}) + +test('plain Linux and macOS reserve nothing', () => { + assert.equal(nativeOverlayWidth({ isWindows: false, isWsl: false }), 0) + assert.equal(nativeOverlayWidth(), 0) + assert.equal(nativeOverlayWidth({}), 0) +}) + +test('the fallback width is a sane positive pixel value', () => { + assert.ok(Number.isInteger(OVERLAY_FALLBACK_WIDTH) && OVERLAY_FALLBACK_WIDTH > 0) +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8b26d89b3bc..56a7374eeef 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -37,7 +37,7 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/window-state.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", diff --git a/apps/desktop/src/app/chat/right-rail/preview.tsx b/apps/desktop/src/app/chat/right-rail/preview.tsx index d13eb088967..bc34e4b2316 100644 --- a/apps/desktop/src/app/chat/right-rail/preview.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview.tsx @@ -101,6 +101,12 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP 'relative flex h-full w-full min-w-0 flex-col overflow-hidden border-(--ui-stroke-tertiary) bg-(--ui-editor-surface-background) text-(--ui-text-tertiary)', panesFlipped ? 'border-r' : 'border-l' )} + // Windows/WSLg paint Electron's Window Controls Overlay across our + // titlebar band, so the editor-style tab strip (which normally sits IN that + // band) would land under the fixed titlebar tools. --right-rail-top-inset + // (set by AppShell only when the overlay is present) drops the rail one + // titlebar-height so it opens below the band. 0px elsewhere → unchanged. + style={{ paddingTop: 'var(--right-rail-top-inset, 0px)' }} >
0 ? `${nativeOverlayWidth}px` : '0.75rem' + // When the native window controls overlay our titlebar band — Windows and + // WSLg both paint Electron's Window Controls Overlay and report + // nativeOverlayWidth > 0 — the right rail's editor-style tab strip (which + // normally lives IN that band) would render at y=0 under the fixed titlebar + // tool cluster and collide with it. Drop the right rail one titlebar-height so + // it opens BELOW the band. macOS / plain Linux paint no overlay → 0 inset, + // layout byte-for-byte unchanged. Consumed as --right-rail-top-inset. + const rightRailTopInset = nativeOverlayWidth > 0 ? 'var(--titlebar-height)' : '0px' + // The inset clears the top-left titlebar buttons when nothing covers the // window's left edge. Default layout: the sessions sidebar sits there. // Flipped layout: the file browser does instead. Both force-collapse to a @@ -159,6 +174,9 @@ export function AppShell({ '--titlebar-controls-top': `${titlebarControls.top}px`, '--titlebar-tools-right': titlebarToolsRight, '--titlebar-tools-width': titlebarToolsWidth, + // Drops the right rail below the titlebar band when the OS/host paints + // window controls over it (Windows/WSLg); 0px elsewhere. + '--right-rail-top-inset': rightRailTopInset, // Anchor for the pane-tool cluster's right edge in TitlebarControls. // Sourced from the layout store rather than the PaneShell-emitted // --pane-*-width vars because the titlebar is a sibling of PaneShell diff --git a/apps/desktop/src/app/shell/hooks/use-window-controls-overlay-width.ts b/apps/desktop/src/app/shell/hooks/use-window-controls-overlay-width.ts new file mode 100644 index 00000000000..f2411f99513 --- /dev/null +++ b/apps/desktop/src/app/shell/hooks/use-window-controls-overlay-width.ts @@ -0,0 +1,68 @@ +import { useEffect, useState } from 'react' + +// Measure the EXACT width of the native window-controls overlay (min/max/close) +// straight from the browser, instead of a hardcoded reservation. +// +// When Electron's Window Controls Overlay is active (native Windows AND WSLg), +// Chromium exposes `navigator.windowControlsOverlay`. Its getTitlebarAreaRect() +// returns the draggable title-bar rect that EXCLUDES the controls, so the +// controls' width on the right is `innerWidth - rect.right`. This is precise and +// self-correcting across DPI / host themes / window states — no magic numbers, +// and it sidesteps the WSLg-vs-Windows footprint guesswork. +// +// Returns null when WCO is unavailable (macOS, plain Linux, or before first +// layout), so callers fall back to the static reservation from the main process. + +interface WindowControlsOverlayLike { + visible: boolean + getTitlebarAreaRect: () => DOMRect + addEventListener: (type: 'geometrychange', cb: () => void) => void + removeEventListener: (type: 'geometrychange', cb: () => void) => void +} + +const overlay = () => + (navigator as Navigator & { windowControlsOverlay?: WindowControlsOverlayLike }).windowControlsOverlay ?? null + +function measure(wco: WindowControlsOverlayLike | null): number | null { + const rect = wco?.visible ? wco.getTitlebarAreaRect() : null + + // No overlay, or it isn't laid out yet. + if (!rect?.width) { + return null + } + + const width = Math.round(window.innerWidth - rect.right) + + return width > 0 ? width : null +} + +/** + * Live width (px) of the right-side native window-controls overlay, or null when + * the platform/build exposes no overlay (caller should use the static fallback). + */ +export function useWindowControlsOverlayWidth(): number | null { + const [width, setWidth] = useState(() => measure(overlay())) + + useEffect(() => { + const wco = overlay() + + if (!wco) { + return + } + + const update = () => setWidth(measure(wco)) + + // Re-measure on overlay geometry changes (maximize/restore, DPI) and on + // window resize (innerWidth feeds the calc). + wco.addEventListener('geometrychange', update) + window.addEventListener('resize', update) + update() + + return () => { + wco.removeEventListener('geometrychange', update) + window.removeEventListener('resize', update) + } + }, []) + + return width +} diff --git a/apps/desktop/src/app/shell/titlebar-controls.tsx b/apps/desktop/src/app/shell/titlebar-controls.tsx index d0ace1c8838..fb0cae4307e 100644 --- a/apps/desktop/src/app/shell/titlebar-controls.tsx +++ b/apps/desktop/src/app/shell/titlebar-controls.tsx @@ -175,7 +175,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: {visiblePaneTools.length > 0 && (
{visiblePaneTools.map(tool => (