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)' }} >