From 0a58cc5e26af8082ae095a95c582f84b19afa8eb Mon Sep 17 00:00:00 2001 From: tt-a1i <53142663+tt-a1i@users.noreply.github.com> Date: Fri, 19 Jun 2026 03:21:34 +0800 Subject: [PATCH] fix(desktop): avoid linux hidden titlebar freeze --- apps/desktop/electron/main.cjs | 60 ++++++++++++------ apps/desktop/electron/window-chrome.cjs | 41 ++++++++++++ apps/desktop/electron/window-chrome.test.cjs | 63 +++++++++++++++++++ apps/desktop/package.json | 2 +- .../desktop/src/app/overlays/overlay-view.tsx | 11 +++- apps/desktop/src/app/shell/app-shell.tsx | 12 +++- apps/desktop/src/app/shell/titlebar.ts | 8 ++- .../components/assistant-ui/thread-list.tsx | 17 +++-- apps/desktop/src/global.d.ts | 2 + apps/desktop/src/store/updates.test.ts | 32 ++++++++-- 10 files changed, 214 insertions(+), 34 deletions(-) create mode 100644 apps/desktop/electron/window-chrome.cjs create mode 100644 apps/desktop/electron/window-chrome.test.cjs diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index be89c6c91cf1..d5f883e080fd 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -20,7 +20,6 @@ const crypto = require('node:crypto') const fs = require('node:fs') const http = require('node:http') const https = require('node:https') -const net = require('node:net') const path = require('node:path') const { pathToFileURL } = require('node:url') const { execFileSync, spawn } = require('node:child_process') @@ -33,6 +32,11 @@ const { SESSION_WINDOW_MIN_HEIGHT, SESSION_WINDOW_MIN_WIDTH } = require('./session-windows.cjs') +const { + chatWindowChromeOptions, + nativeOverlayWidthForPlatform, + usesNativeSystemTitleBar +} = require('./window-chrome.cjs') const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs') const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') const { adoptServedDashboardToken } = require('./dashboard-token.cjs') @@ -485,6 +489,23 @@ function getTitleBarOverlayOptions() { } } +function getChatWindowChromeOptions() { + return chatWindowChromeOptions({ + platform: process.platform, + isMac: IS_MAC, + titleBarOverlay: getTitleBarOverlayOptions(), + trafficLightPosition: WINDOW_BUTTON_POSITION + }) +} + +function applyTitleBarOverlay(win) { + if (usesNativeSystemTitleBar(process.platform)) { + return + } + + win?.setTitleBarOverlay?.(getTitleBarOverlayOptions()) +} + const MEDIA_MIME_TYPES = { '.avi': 'video/x-msvideo', '.bmp': 'image/bmp', @@ -3298,16 +3319,22 @@ function getWindowButtonPosition() { function getNativeOverlayWidth() { // macOS reports traffic-light coords via windowButtonPosition; the - // titlebarOverlay there doesn't reserve right-edge space. Windows/Linux - // render the native window-controls overlay on the right, so the renderer - // needs to inset its right cluster by this much to clear them. - return IS_MAC ? 0 : NATIVE_OVERLAY_BUTTON_WIDTH + // titlebarOverlay there doesn't reserve right-edge space. Windows renders the + // native window-controls overlay on the right, so the renderer needs to inset + // its right cluster by this much to clear them. Linux keeps the system + // titlebar, so there is no renderer overlay to clear. + return nativeOverlayWidthForPlatform({ + platform: process.platform, + isMac: IS_MAC, + nativeOverlayButtonWidth: NATIVE_OVERLAY_BUTTON_WIDTH + }) } function getWindowState() { return { isFullscreen: Boolean(mainWindow?.isFullScreen?.()), nativeOverlayWidth: getNativeOverlayWidth(), + usesNativeTitleBar: usesNativeSystemTitleBar(process.platform), windowButtonPosition: getWindowButtonPosition() } } @@ -5098,9 +5125,7 @@ function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) { minWidth: SESSION_WINDOW_MIN_WIDTH, minHeight: SESSION_WINDOW_MIN_HEIGHT, title: 'Hermes', - titleBarStyle: 'hidden', - titleBarOverlay: getTitleBarOverlayOptions(), - trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined, + ...getChatWindowChromeOptions(), vibrancy: IS_MAC ? 'sidebar' : undefined, opacity: windowOpacity(), icon, @@ -5162,15 +5187,12 @@ function createWindow() { minWidth: 400, minHeight: 620, title: 'Hermes', - // Frameless title bar on every platform so the renderer can paint the - // "hide sidebar" button (and other left-side titlebar tools) flush with - // the top edge — matching the macOS layout where the traffic lights sit - // inside the same band. On Windows/Linux, titleBarOverlay tells Electron - // to paint native min/max/close in the top-right of the renderer; on - // macOS it just reserves a content inset alongside the traffic lights. - titleBarStyle: 'hidden', - titleBarOverlay: getTitleBarOverlayOptions(), - trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined, + // Frameless title bar where Electron's custom chrome is stable so the + // renderer can paint the "hide sidebar" button (and other left-side + // titlebar tools) flush with the top edge. Linux uses the system titlebar: + // on GNOME/X11 the hidden-titlebar drag region can expose a window menu + // whose selected actions freeze the desktop session. + ...getChatWindowChromeOptions(), vibrancy: IS_MAC ? 'sidebar' : undefined, opacity: windowOpacity(), icon, @@ -5197,7 +5219,7 @@ function createWindow() { if (!nativeThemeListenerInstalled) { nativeThemeListenerInstalled = true nativeTheme.on('updated', () => { - mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions()) + applyTitleBarOverlay(mainWindow) }) } } @@ -5756,7 +5778,7 @@ ipcMain.on('hermes:titlebar-theme', (_event, payload) => { background: payload.background, foreground: payload.foreground } - mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions()) + applyTitleBarOverlay(mainWindow) }) // Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH). diff --git a/apps/desktop/electron/window-chrome.cjs b/apps/desktop/electron/window-chrome.cjs new file mode 100644 index 000000000000..06f3c03ec656 --- /dev/null +++ b/apps/desktop/electron/window-chrome.cjs @@ -0,0 +1,41 @@ +// BrowserWindow titlebar/chrome policy kept pure so platform-specific choices +// can be tested without booting Electron. + +function usesNativeSystemTitleBar(platform = process.platform) { + return platform === 'linux' +} + +function chatWindowChromeOptions({ + platform = process.platform, + isMac = platform === 'darwin', + titleBarOverlay, + trafficLightPosition +} = {}) { + if (usesNativeSystemTitleBar(platform)) { + return {} + } + + return { + titleBarStyle: 'hidden', + titleBarOverlay, + trafficLightPosition: isMac ? trafficLightPosition : undefined + } +} + +function nativeOverlayWidthForPlatform({ + platform = process.platform, + isMac = platform === 'darwin', + nativeOverlayButtonWidth +} = {}) { + if (isMac || usesNativeSystemTitleBar(platform)) { + return 0 + } + + return nativeOverlayButtonWidth +} + +module.exports = { + chatWindowChromeOptions, + nativeOverlayWidthForPlatform, + usesNativeSystemTitleBar +} diff --git a/apps/desktop/electron/window-chrome.test.cjs b/apps/desktop/electron/window-chrome.test.cjs new file mode 100644 index 000000000000..36e77e4803d4 --- /dev/null +++ b/apps/desktop/electron/window-chrome.test.cjs @@ -0,0 +1,63 @@ +const assert = require('node:assert/strict') +const test = require('node:test') + +const { + chatWindowChromeOptions, + nativeOverlayWidthForPlatform, + usesNativeSystemTitleBar +} = require('./window-chrome.cjs') + +const overlay = { color: '#111111', height: 34, symbolColor: '#f7f7f7' } +const trafficLightPosition = { x: 24, y: 10 } + +test('Linux keeps the system titlebar and avoids Electron titlebar overlay', () => { + const options = chatWindowChromeOptions({ + platform: 'linux', + titleBarOverlay: overlay, + trafficLightPosition + }) + + assert.deepEqual(options, {}) + assert.equal(usesNativeSystemTitleBar('linux'), true) +}) + +test('macOS keeps the hidden titlebar and traffic-light placement', () => { + const options = chatWindowChromeOptions({ + platform: 'darwin', + isMac: true, + titleBarOverlay: { height: 34 }, + trafficLightPosition + }) + + assert.deepEqual(options, { + titleBarStyle: 'hidden', + titleBarOverlay: { height: 34 }, + trafficLightPosition + }) +}) + +test('Windows keeps hidden titlebar overlay without macOS traffic-light options', () => { + const options = chatWindowChromeOptions({ + platform: 'win32', + isMac: false, + titleBarOverlay: overlay, + trafficLightPosition + }) + + assert.deepEqual(options, { + titleBarStyle: 'hidden', + titleBarOverlay: overlay, + trafficLightPosition: undefined + }) +}) + +test('native overlay width is reserved only for non-Linux non-macOS overlay chrome', () => { + const width = 144 + + assert.equal(nativeOverlayWidthForPlatform({ platform: 'darwin', isMac: true, nativeOverlayButtonWidth: width }), 0) + assert.equal(nativeOverlayWidthForPlatform({ platform: 'linux', isMac: false, nativeOverlayButtonWidth: width }), 0) + assert.equal( + nativeOverlayWidthForPlatform({ platform: 'win32', isMac: false, nativeOverlayButtonWidth: width }), + width + ) +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 70d35fb7bb08..c191812b3a11 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/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/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/windows-user-env.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/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/window-chrome.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/windows-user-env.test.cjs", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", diff --git a/apps/desktop/src/app/overlays/overlay-view.tsx b/apps/desktop/src/app/overlays/overlay-view.tsx index 8e429c3884ae..a0f47390af2b 100644 --- a/apps/desktop/src/app/overlays/overlay-view.tsx +++ b/apps/desktop/src/app/overlays/overlay-view.tsx @@ -1,3 +1,4 @@ +import { useStore } from '@nanostores/react' import { type ReactNode, useEffect } from 'react' import { Button } from '@/components/ui/button' @@ -5,6 +6,7 @@ import { Codicon } from '@/components/ui/codicon' import { translateNow } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' +import { $connection } from '@/store/session' interface OverlayViewProps { children: ReactNode @@ -23,6 +25,8 @@ export function OverlayView({ headerContent, rootClassName }: OverlayViewProps) { + const usesNativeTitleBar = Boolean(useStore($connection)?.usesNativeTitleBar) + const closeOverlay = () => { triggerHaptic('close') onClose() @@ -63,7 +67,12 @@ export function OverlayView({ rootClassName )} > -
+
{headerContent && (
{headerContent} diff --git a/apps/desktop/src/app/shell/app-shell.tsx b/apps/desktop/src/app/shell/app-shell.tsx index 7cbcaacfb418..037bc952efa7 100644 --- a/apps/desktop/src/app/shell/app-shell.tsx +++ b/apps/desktop/src/app/shell/app-shell.tsx @@ -6,6 +6,7 @@ import { NotificationStack } from '@/components/notifications' import { PaneShell } from '@/components/pane-shell' import { SidebarProvider } from '@/components/ui/sidebar' import { useMediaQuery } from '@/hooks/use-media-query' +import { cn } from '@/lib/utils' import { $fileBrowserOpen, $panesFlipped, @@ -78,6 +79,7 @@ export function AppShell({ const narrowViewport = useMediaQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY) const fileBrowserWidthOverride = useStore($paneWidthOverride(FILE_BROWSER_PANE_ID)) const connection = useStore($connection) + const usesNativeTitleBar = Boolean(connection?.usesNativeTitleBar) const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false) const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen // Every secondary window (new-session scratch, subagent watch, cmd-click @@ -174,11 +176,17 @@ export function AppShell({