Merge pull request #68 from Psypeal/feat/linux-custom-titlebar

feat: custom title bar on Linux with native toggle
This commit is contained in:
matt 2026-02-24 14:23:08 +08:00 committed by GitHub
commit 1233a6f155
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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: {