From 6cc42992457319eb5821ce17eecf5440a07d5a5d Mon Sep 17 00:00:00 2001 From: Psypeal Gwai Date: Sun, 22 Feb 2026 05:58:08 -0800 Subject: [PATCH] feat: custom title bar on Linux with native toggle Replace WindowsTitleBar with CustomTitleBar that renders on both Windows and Linux. Add useNativeTitleBar settings toggle with app relaunch to switch between custom and native window frame. --- src/main/index.ts | 5 ++-- src/main/ipc/configValidation.ts | 7 +++++ src/main/ipc/window.ts | 8 ++++- .../services/infrastructure/ConfigManager.ts | 2 ++ src/preload/constants/ipcChannels.ts | 3 ++ src/preload/index.ts | 2 ++ src/renderer/api/httpClient.ts | 1 + ...WindowsTitleBar.tsx => CustomTitleBar.tsx} | 29 +++++++++---------- .../components/layout/TabbedLayout.tsx | 4 +-- .../settings/hooks/useSettingsConfig.ts | 2 ++ .../settings/hooks/useSettingsHandlers.ts | 1 + .../settings/sections/GeneralSection.tsx | 25 ++++++++++++++++ src/shared/types/api.ts | 1 + src/shared/types/notifications.ts | 2 ++ 14 files changed, 72 insertions(+), 20 deletions(-) rename src/renderer/components/layout/{WindowsTitleBar.tsx => CustomTitleBar.tsx} (74%) diff --git a/src/main/index.ts b/src/main/index.ts index 6252481a..81a33cfd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -424,8 +424,8 @@ function syncTrafficLightPosition(win: BrowserWindow): void { */ function createWindow(): void { const isMac = process.platform === 'darwin'; - const isLinux = process.platform === 'linux'; const iconPath = isMac ? undefined : getWindowIconPath(); + const useNativeTitleBar = !isMac && configManager.getConfig().general.useNativeTitleBar; mainWindow = new BrowserWindow({ width: DEFAULT_WINDOW_WIDTH, height: DEFAULT_WINDOW_HEIGHT, @@ -436,7 +436,7 @@ function createWindow(): void { contextIsolation: true, }, backgroundColor: '#1a1a1a', - ...(isLinux ? {} : { titleBarStyle: 'hidden' as const }), + ...(useNativeTitleBar ? {} : { titleBarStyle: 'hidden' as const }), ...(isMac && { trafficLightPosition: getTrafficLightPositionForZoom(1) }), title: 'claude-devtools', }); @@ -485,6 +485,7 @@ function createWindow(): void { // Prevent Electron's default Ctrl+R / Cmd+R page reload so the renderer // keyboard handler can use it as "Refresh Session" (fixes #58). + // Also prevent Ctrl+Shift+R / Cmd+Shift+R (hard reload). if ((input.control || input.meta) && input.key.toLowerCase() === 'r') { event.preventDefault(); return; diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index b860bd15..aa71e865 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -204,6 +204,7 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V 'defaultTab', 'claudeRootPath', 'autoExpandAIGroups', + 'useNativeTitleBar', ]; const result: Partial = {}; @@ -274,6 +275,12 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V } result.autoExpandAIGroups = value; break; + case 'useNativeTitleBar': + if (typeof value !== 'boolean') { + return { valid: false, error: `general.${key} must be a boolean` }; + } + result.useNativeTitleBar = value; + break; default: return { valid: false, error: `Unsupported general key: ${key}` }; } diff --git a/src/main/ipc/window.ts b/src/main/ipc/window.ts index d2cb1edc..06547659 100644 --- a/src/main/ipc/window.ts +++ b/src/main/ipc/window.ts @@ -5,7 +5,7 @@ */ import { createLogger } from '@shared/utils/logger'; -import { BrowserWindow, type IpcMain } from 'electron'; +import { app, BrowserWindow, type IpcMain } from 'electron'; const logger = createLogger('IPC:window'); @@ -40,6 +40,11 @@ export function registerWindowHandlers(ipcMain: IpcMain): void { return win != null && !win.isDestroyed() && win.isMaximized(); }); + ipcMain.handle('app:relaunch', () => { + app.relaunch(); + app.exit(0); + }); + logger.info('Window handlers registered'); } @@ -48,5 +53,6 @@ export function removeWindowHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler('window:maximize'); ipcMain.removeHandler('window:close'); ipcMain.removeHandler('window:isMaximized'); + ipcMain.removeHandler('app:relaunch'); logger.info('Window handlers removed'); } diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index 7f3ea4a3..6d4ff8dc 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -182,6 +182,7 @@ export interface GeneralConfig { defaultTab: 'dashboard' | 'last-session'; claudeRootPath: string | null; autoExpandAIGroups: boolean; + useNativeTitleBar: boolean; } export interface DisplayConfig { @@ -250,6 +251,7 @@ const DEFAULT_CONFIG: AppConfig = { defaultTab: 'dashboard', claudeRootPath: null, autoExpandAIGroups: false, + useNativeTitleBar: false, }, display: { showTimestamps: true, diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index eec0bc05..408e9041 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -171,3 +171,6 @@ export const WINDOW_CLOSE = 'window:close'; /** Whether the window is currently maximized */ export const WINDOW_IS_MAXIMIZED = 'window:isMaximized'; + +/** Relaunch the application */ +export const APP_RELAUNCH = 'app:relaunch'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 09f16c5c..e9c32ae6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,6 +2,7 @@ import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants'; import { contextBridge, ipcRenderer } from 'electron'; import { + APP_RELAUNCH, CONTEXT_CHANGED, CONTEXT_GET_ACTIVE, CONTEXT_LIST, @@ -357,6 +358,7 @@ const electronAPI: ElectronAPI = { maximize: () => ipcRenderer.invoke(WINDOW_MAXIMIZE), close: () => ipcRenderer.invoke(WINDOW_CLOSE), isMaximized: () => ipcRenderer.invoke(WINDOW_IS_MAXIMIZED) as Promise, + relaunch: () => ipcRenderer.invoke(APP_RELAUNCH), }, onTodoChange: (callback: (event: IpcFileChangePayload) => void): (() => void) => { diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 0b2d851f..96868677 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -512,6 +512,7 @@ export class HttpAPIClient implements ElectronAPI { maximize: async (): Promise => {}, close: async (): Promise => {}, isMaximized: async (): Promise => false, + relaunch: async (): Promise => {}, }; // --------------------------------------------------------------------------- diff --git a/src/renderer/components/layout/WindowsTitleBar.tsx b/src/renderer/components/layout/CustomTitleBar.tsx similarity index 74% rename from src/renderer/components/layout/WindowsTitleBar.tsx rename to src/renderer/components/layout/CustomTitleBar.tsx index bde601c0..04895818 100644 --- a/src/renderer/components/layout/WindowsTitleBar.tsx +++ b/src/renderer/components/layout/CustomTitleBar.tsx @@ -1,32 +1,36 @@ /** - * WindowsTitleBar - Conventional title bar for Windows when the native frame is hidden. + * CustomTitleBar - Conventional title bar for Windows and Linux when the native frame is hidden. * * Renders a draggable top strip with window controls (minimize, maximize/restore, close) - * on the right, matching Windows conventions. Only shown in Electron on Windows (win32). + * on the right. Only shown in Electron on Windows or Linux (macOS uses native traffic lights). */ import { useEffect, useState } from 'react'; import { isElectronMode } from '@renderer/api'; +import faviconUrl from '@renderer/favicon.png'; +import { useStore } from '@renderer/store'; import { Minus, Square, X } from 'lucide-react'; const TITLE_BAR_HEIGHT = 32; -function isWindowsDesktop(): boolean { +function needsCustomTitleBar(): boolean { if (!isElectronMode()) return false; - return window.navigator.userAgent.includes('Windows'); + const ua = window.navigator.userAgent; + return ua.includes('Windows') || ua.includes('Linux'); } -export const WindowsTitleBar = (): React.JSX.Element | null => { +export const CustomTitleBar = (): React.JSX.Element | null => { const [isMaximized, setIsMaximized] = useState(false); - const isWin = isWindowsDesktop(); + const useNativeTitleBar = useStore((s) => s.appConfig?.general?.useNativeTitleBar ?? false); + const showTitleBar = needsCustomTitleBar() && !useNativeTitleBar; const api = typeof window !== 'undefined' ? window.electronAPI?.windowControls : null; useEffect(() => { if (api) void api.isMaximized().then(setIsMaximized); }, [api]); - if (!isWin || !api) return null; + if (!showTitleBar || !api) return null; const { minimize, maximize, close, isMaximized: getIsMaximized } = api; @@ -49,14 +53,9 @@ export const WindowsTitleBar = (): React.JSX.Element | null => { return (
- {/* Draggable area — app title optional */} -
- - claude-devtools - + {/* Draggable area — app icon */} +
+
{/* Window controls — no-drag so they receive clicks */} diff --git a/src/renderer/components/layout/TabbedLayout.tsx b/src/renderer/components/layout/TabbedLayout.tsx index 7abcca82..e466d6b5 100644 --- a/src/renderer/components/layout/TabbedLayout.tsx +++ b/src/renderer/components/layout/TabbedLayout.tsx @@ -16,9 +16,9 @@ import { UpdateDialog } from '../common/UpdateDialog'; import { WorkspaceIndicator } from '../common/WorkspaceIndicator'; import { CommandPalette } from '../search/CommandPalette'; +import { CustomTitleBar } from './CustomTitleBar'; import { PaneContainer } from './PaneContainer'; import { Sidebar } from './Sidebar'; -import { WindowsTitleBar } from './WindowsTitleBar'; export const TabbedLayout = (): React.JSX.Element => { // Enable keyboard shortcuts @@ -33,7 +33,7 @@ export const TabbedLayout = (): React.JSX.Element => { { '--macos-traffic-light-padding-left': `${trafficLightPadding}px` } as React.CSSProperties } > - +
{/* Command Palette (Cmd+K) */} diff --git a/src/renderer/components/settings/hooks/useSettingsConfig.ts b/src/renderer/components/settings/hooks/useSettingsConfig.ts index 8f4fbdc4..192346ae 100644 --- a/src/renderer/components/settings/hooks/useSettingsConfig.ts +++ b/src/renderer/components/settings/hooks/useSettingsConfig.ts @@ -31,6 +31,7 @@ export interface SafeConfig { defaultTab: 'dashboard' | 'last-session'; claudeRootPath: string | null; autoExpandAIGroups: boolean; + useNativeTitleBar: boolean; }; notifications: { enabled: boolean; @@ -156,6 +157,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn { defaultTab: displayConfig?.general?.defaultTab ?? 'dashboard', claudeRootPath: displayConfig?.general?.claudeRootPath ?? null, autoExpandAIGroups: displayConfig?.general?.autoExpandAIGroups ?? false, + useNativeTitleBar: displayConfig?.general?.useNativeTitleBar ?? false, }, notifications: { enabled: displayConfig?.notifications?.enabled ?? true, diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts index 40a9997f..617e2c45 100644 --- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts +++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts @@ -288,6 +288,7 @@ export function useSettingsHandlers({ defaultTab: 'dashboard', claudeRootPath: null, autoExpandAIGroups: false, + useNativeTitleBar: false, }, display: { showTimestamps: true, diff --git a/src/renderer/components/settings/sections/GeneralSection.tsx b/src/renderer/components/settings/sections/GeneralSection.tsx index deca9d3f..1ed1ae20 100644 --- a/src/renderer/components/settings/sections/GeneralSection.tsx +++ b/src/renderer/components/settings/sections/GeneralSection.tsx @@ -297,6 +297,31 @@ export const GeneralSection = ({ disabled={saving} /> + {isElectron && !window.navigator.userAgent.includes('Macintosh') && ( + + { + const shouldRelaunch = await confirm({ + title: 'Restart required', + message: 'The app needs to restart to apply the title bar change. Restart now?', + confirmLabel: 'Restart', + }); + if (shouldRelaunch) { + onGeneralToggle('useNativeTitleBar', v); + // Small delay to let config persist before relaunch + setTimeout(() => { + void window.electronAPI?.windowControls?.relaunch(); + }, 200); + } + }} + disabled={saving} + /> + + )} {isElectron && ( <> diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 351cf3d1..81960cea 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -432,6 +432,7 @@ export interface ElectronAPI { maximize: () => Promise; close: () => Promise; isMaximized: () => Promise; + relaunch: () => Promise; }; // Updater API diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index 10dadc50..766d7044 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -264,6 +264,8 @@ export interface AppConfig { claudeRootPath: string | null; /** Whether to auto-expand AI response groups when opening a transcript or receiving new messages */ autoExpandAIGroups: boolean; + /** Whether to use the native OS title bar instead of the custom one (Linux/Windows) */ + useNativeTitleBar: boolean; }; /** Display and UI settings */ display: {