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.

This commit is contained in:
Psypeal Gwai 2026-02-22 05:58:08 -08:00
parent 68f16bb717
commit 6cc4299245
14 changed files with 72 additions and 20 deletions

View file

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

View file

@ -204,6 +204,7 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
'defaultTab',
'claudeRootPath',
'autoExpandAIGroups',
'useNativeTitleBar',
];
const result: Partial<GeneralConfig> = {};
@ -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}` };
}

View file

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

View file

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

View file

@ -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';

View file

@ -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<boolean>,
relaunch: () => ipcRenderer.invoke(APP_RELAUNCH),
},
onTodoChange: (callback: (event: IpcFileChangePayload) => void): (() => void) => {

View file

@ -512,6 +512,7 @@ export class HttpAPIClient implements ElectronAPI {
maximize: async (): Promise<void> => {},
close: async (): Promise<void> => {},
isMaximized: async (): Promise<boolean> => false,
relaunch: async (): Promise<void> => {},
};
// ---------------------------------------------------------------------------

View file

@ -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 (
<div className="flex shrink-0 select-none items-stretch" style={titleBarStyle}>
{/* Draggable area — app title optional */}
<div className="flex flex-1 items-center pl-4" style={{ minWidth: 0 }}>
<span
className="truncate text-sm font-semibold"
style={{ color: 'var(--color-text-muted)' }}
>
claude-devtools
</span>
{/* Draggable area — app icon */}
<div className="flex flex-1 items-center pl-3" style={{ minWidth: 0 }}>
<img src={faviconUrl} alt="" className="size-5 shrink-0 rounded-sm" draggable={false} />
</div>
{/* Window controls — no-drag so they receive clicks */}

View file

@ -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
}
>
<WindowsTitleBar />
<CustomTitleBar />
<UpdateBanner />
<div className="flex flex-1 overflow-hidden">
{/* Command Palette (Cmd+K) */}

View file

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

View file

@ -288,6 +288,7 @@ export function useSettingsHandlers({
defaultTab: 'dashboard',
claudeRootPath: null,
autoExpandAIGroups: false,
useNativeTitleBar: false,
},
display: {
showTimestamps: true,

View file

@ -297,6 +297,31 @@ export const GeneralSection = ({
disabled={saving}
/>
</SettingRow>
{isElectron && !window.navigator.userAgent.includes('Macintosh') && (
<SettingRow
label="Use native title bar"
description="Use the default system window frame instead of the custom title bar"
>
<SettingsToggle
enabled={safeConfig.general.useNativeTitleBar}
onChange={async (v) => {
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}
/>
</SettingRow>
)}
{isElectron && (
<>

View file

@ -432,6 +432,7 @@ export interface ElectronAPI {
maximize: () => Promise<void>;
close: () => Promise<void>;
isMaximized: () => Promise<boolean>;
relaunch: () => Promise<void>;
};
// Updater API

View file

@ -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: {