fix(desktop): WSL titlebar layout and WSL2 GPU acceleration

Live-measure WCO width in the renderer, drop the right rail below the titlebar
band, and re-enable GPU compositing under WSLg when /dev/dxg is present.
This commit is contained in:
Brooklyn Nicholson 2026-06-25 23:50:59 -05:00
parent da5484b61f
commit 3b1344c18c
8 changed files with 162 additions and 30 deletions

View file

@ -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() {

View file

@ -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 }

View file

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

View file

@ -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",

View file

@ -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)' }}
>
<div className="group/rail-tabs flex h-(--titlebar-height) shrink-0 border-b border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background)">
<div

View file

@ -21,6 +21,7 @@ import { isSecondaryWindow } from '@/store/windows'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
import { useWindowControlsOverlayWidth } from './hooks/use-window-controls-overlay-width'
import { KeybindPanel } from './keybind-panel'
import { StatusbarControls, type StatusbarItem } from './statusbar-controls'
import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar'
@ -86,12 +87,26 @@ export function AppShell({
// tool cluster. Gate on isSecondaryWindow, never the narrower new-session flag.
const hideTitlebarControls = isSecondaryWindow()
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
// Width Windows/Linux reserve for the OS-painted min/max/close overlay (zero
// on macOS, where window controls sit on the left and are reported via
// Width Windows/WSLg reserve for the native min/max/close overlay (zero on
// macOS, where window controls sit on the left and are reported via
// windowButtonPosition instead). The right tool cluster has to clear them.
const nativeOverlayWidth = connection?.nativeOverlayWidth ?? 0
// Prefer the EXACT width measured from the live Window Controls Overlay
// (precise + self-correcting across DPI/host themes); fall back to the static
// reservation the main process sends when the WCO API isn't available.
const measuredOverlayWidth = useWindowControlsOverlayWidth()
const staticOverlayWidth = connection?.nativeOverlayWidth ?? 0
const nativeOverlayWidth = measuredOverlayWidth ?? staticOverlayWidth
const titlebarToolsRight = nativeOverlayWidth > 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

View file

@ -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<number | null>(() => 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
}

View file

@ -175,7 +175,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
{visiblePaneTools.length > 0 && (
<div
aria-label={t.shell.paneControls}
className="fixed top-(--titlebar-controls-top) right-[calc(var(--titlebar-tools-right)+var(--shell-preview-toolbar-gap,0))] z-70 flex flex-row items-center gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
className="fixed top-[calc(var(--titlebar-controls-top)+var(--right-rail-top-inset,0px))] right-[calc(var(--titlebar-tools-right)+var(--shell-preview-toolbar-gap,0))] z-70 flex flex-row items-center gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
>
{visiblePaneTools.map(tool => (
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />