fix(mac): align traffic lights in sidebar chrome (#1092)
This commit is contained in:
@@ -28,6 +28,8 @@ import {
|
||||
} from '../utils/openclaw-workspace';
|
||||
import { autoInstallCliIfNeeded, generateCompletionCache, installCompletionToProfile } from '../utils/openclaw-cli';
|
||||
import { isQuitting, setQuitting } from './app-state';
|
||||
import { getMacTrafficLightPosition, syncMacTrafficLightPosition } from './traffic-light-layout';
|
||||
import { getSetting } from '../utils/store';
|
||||
import { applyProxySettings } from './proxy';
|
||||
import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup';
|
||||
import {
|
||||
@@ -43,7 +45,6 @@ import {
|
||||
} from './quit-lifecycle';
|
||||
import { createSignalQuitHandler } from './signal-quit';
|
||||
import { acquireProcessInstanceFileLock } from './process-instance-lock';
|
||||
import { getSetting } from '../utils/store';
|
||||
import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled, trimBundledOpenClawSkillsAndConfigs } from '../utils/skill-config';
|
||||
|
||||
import { startHostApiServer } from '../api/server';
|
||||
@@ -187,7 +188,9 @@ function createWindow(): BrowserWindow {
|
||||
webviewTag: true, // Enable <webview> for embedding OpenClaw Control UI
|
||||
},
|
||||
titleBarStyle: isMac ? 'hiddenInset' : useCustomTitleBar ? 'hidden' : 'default',
|
||||
trafficLightPosition: isMac ? { x: 16, y: 16 } : undefined,
|
||||
trafficLightPosition: isMac
|
||||
? getMacTrafficLightPosition(false)
|
||||
: undefined,
|
||||
frame: isMac || !useCustomTitleBar,
|
||||
show: false,
|
||||
});
|
||||
@@ -261,6 +264,12 @@ function createMainWindow(): BrowserWindow {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
void getSetting('sidebarCollapsed').then((sidebarCollapsed) => {
|
||||
syncMacTrafficLightPosition(win, sidebarCollapsed);
|
||||
});
|
||||
}
|
||||
|
||||
const action = consumeMainWindowReady(mainWindowFocusState);
|
||||
if (action === 'focus') {
|
||||
focusWindow(win);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, extname, basename, resolve, sep, relative } from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { syncMacTrafficLightPosition } from './traffic-light-layout';
|
||||
import { GatewayManager } from '../gateway/manager';
|
||||
import { ClawHubService, ClawHubSearchParams, ClawHubInstallParams, ClawHubUninstallParams } from '../gateway/clawhub';
|
||||
import {
|
||||
@@ -2305,6 +2306,13 @@ function registerUsageHandlers(): void {
|
||||
* Window control handlers (for custom title bar on Windows)
|
||||
*/
|
||||
function registerWindowHandlers(mainWindow: BrowserWindow): void {
|
||||
ipcMain.handle('window:syncTrafficLightPosition', (_, sidebarCollapsed: unknown) => {
|
||||
if (typeof sidebarCollapsed !== 'boolean') {
|
||||
return;
|
||||
}
|
||||
syncMacTrafficLightPosition(mainWindow, sidebarCollapsed);
|
||||
});
|
||||
|
||||
ipcMain.handle('window:minimize', () => {
|
||||
mainWindow.minimize();
|
||||
});
|
||||
|
||||
40
electron/main/traffic-light-layout.ts
Normal file
40
electron/main/traffic-light-layout.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { release } from 'node:os';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
const MAC_SIDEBAR_CHROME_HEIGHT = 28;
|
||||
const MAC_TRAFFIC_LIGHT_GAP = 8;
|
||||
const MAC_TRAFFIC_LIGHT_FRAME_HEIGHT = 16;
|
||||
const MAC_TRAFFIC_LIGHT_FRAME_HEIGHT_TAHOE = 14;
|
||||
|
||||
function getMacTrafficLightFrameHeight(darwinMajor: number): number {
|
||||
return darwinMajor >= 25
|
||||
? MAC_TRAFFIC_LIGHT_FRAME_HEIGHT_TAHOE
|
||||
: MAC_TRAFFIC_LIGHT_FRAME_HEIGHT;
|
||||
}
|
||||
|
||||
function getMacTrafficLightChromeOffset(buttonFrameHeight: number): number {
|
||||
return Math.floor((MAC_SIDEBAR_CHROME_HEIGHT - buttonFrameHeight) / 2);
|
||||
}
|
||||
|
||||
export function getMacTrafficLightPosition(sidebarCollapsed: boolean): { x: number; y: number } {
|
||||
const darwinMajor = Number.parseInt(release().split('.')[0] ?? '0', 10);
|
||||
const buttonFrameHeight = getMacTrafficLightFrameHeight(darwinMajor);
|
||||
const offset = getMacTrafficLightChromeOffset(buttonFrameHeight);
|
||||
|
||||
if (sidebarCollapsed) {
|
||||
return { x: MAC_TRAFFIC_LIGHT_GAP, y: Math.max(MAC_TRAFFIC_LIGHT_GAP, offset) };
|
||||
}
|
||||
|
||||
return { x: offset + 1, y: offset };
|
||||
}
|
||||
|
||||
export function syncMacTrafficLightPosition(
|
||||
win: BrowserWindow,
|
||||
sidebarCollapsed: boolean,
|
||||
): void {
|
||||
if (process.platform !== 'darwin' || win.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
win.setWindowButtonPosition(getMacTrafficLightPosition(sidebarCollapsed));
|
||||
}
|
||||
@@ -50,6 +50,7 @@ const electronAPI = {
|
||||
'window:maximize',
|
||||
'window:close',
|
||||
'window:isMaximized',
|
||||
'window:syncTrafficLightPosition',
|
||||
// Settings
|
||||
'settings:get',
|
||||
'settings:set',
|
||||
|
||||
@@ -87,7 +87,8 @@
|
||||
"node-machine-id": "^1.1.12",
|
||||
"posthog-node": "^5.28.0",
|
||||
"tar": "^6.2.1",
|
||||
"ws": "^8.19.0"
|
||||
"ws": "^8.19.0",
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@buape/carbon": "0.16.0",
|
||||
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -29,6 +29,9 @@ importers:
|
||||
ws:
|
||||
specifier: ^8.19.0
|
||||
version: 8.20.0
|
||||
yaml:
|
||||
specifier: ^2.9.0
|
||||
version: 2.9.0
|
||||
devDependencies:
|
||||
'@buape/carbon':
|
||||
specifier: 0.16.0
|
||||
@@ -4018,10 +4021,6 @@ packages:
|
||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
|
||||
get-east-asian-width@1.5.0:
|
||||
resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
get-east-asian-width@1.6.0:
|
||||
resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -10820,8 +10819,6 @@ snapshots:
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
||||
get-east-asian-width@1.5.0: {}
|
||||
|
||||
get-east-asian-width@1.6.0: {}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
|
||||
58
shared/sidebar-layout.ts
Normal file
58
shared/sidebar-layout.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/** macOS sidebar title strip height — matches VS Code compact title bar (px). */
|
||||
export const MAC_SIDEBAR_CHROME_HEIGHT = 28;
|
||||
|
||||
/** Visible traffic-light diameter on macOS (px). */
|
||||
export const MAC_TRAFFIC_LIGHT_BUTTON_SIZE = 12;
|
||||
|
||||
/** Native edge-to-edge gap between traffic light buttons (px). */
|
||||
export const MAC_TRAFFIC_LIGHT_GAP = 8;
|
||||
|
||||
/** Frame height VS Code uses when vertically centering traffic lights (pre-Tahoe). */
|
||||
export const MAC_TRAFFIC_LIGHT_FRAME_HEIGHT = 16;
|
||||
|
||||
/** Frame height on macOS Tahoe and newer. */
|
||||
export const MAC_TRAFFIC_LIGHT_FRAME_HEIGHT_TAHOE = 14;
|
||||
|
||||
/** macOS close + minimize + zoom button group width (px). */
|
||||
export const MAC_TRAFFIC_LIGHT_GROUP_WIDTH =
|
||||
3 * MAC_TRAFFIC_LIGHT_BUTTON_SIZE + 2 * MAC_TRAFFIC_LIGHT_GAP;
|
||||
|
||||
/**
|
||||
* Collapsed sidebar rail width (px): four spacing units plus three buttons.
|
||||
* 4 × 8 + 3 × 12 = 68
|
||||
*/
|
||||
export const SIDEBAR_COLLAPSED_WIDTH =
|
||||
4 * MAC_TRAFFIC_LIGHT_GAP + 3 * MAC_TRAFFIC_LIGHT_BUTTON_SIZE;
|
||||
|
||||
export function getMacTrafficLightFrameHeight(darwinMajor: number): number {
|
||||
return darwinMajor >= 25
|
||||
? MAC_TRAFFIC_LIGHT_FRAME_HEIGHT_TAHOE
|
||||
: MAC_TRAFFIC_LIGHT_FRAME_HEIGHT;
|
||||
}
|
||||
|
||||
/** VS Code-style inset that vertically centers traffic lights in the chrome strip. */
|
||||
export function getMacTrafficLightChromeOffset(
|
||||
chromeHeight = MAC_SIDEBAR_CHROME_HEIGHT,
|
||||
buttonFrameHeight = MAC_TRAFFIC_LIGHT_FRAME_HEIGHT,
|
||||
): number {
|
||||
return Math.floor((chromeHeight - buttonFrameHeight) / 2);
|
||||
}
|
||||
|
||||
export function getMacTrafficLightPosition(options: {
|
||||
sidebarCollapsed: boolean;
|
||||
chromeHeight?: number;
|
||||
buttonFrameHeight?: number;
|
||||
}): { x: number; y: number } {
|
||||
const chromeHeight = options.chromeHeight ?? MAC_SIDEBAR_CHROME_HEIGHT;
|
||||
const buttonFrameHeight = options.buttonFrameHeight ?? MAC_TRAFFIC_LIGHT_FRAME_HEIGHT;
|
||||
const offset = getMacTrafficLightChromeOffset(chromeHeight, buttonFrameHeight);
|
||||
|
||||
if (options.sidebarCollapsed) {
|
||||
// Collapsed rail: keep uniform 8px grid on left / inter-dot / right.
|
||||
const spacing = MAC_TRAFFIC_LIGHT_GAP;
|
||||
return { x: spacing, y: Math.max(spacing, offset) };
|
||||
}
|
||||
|
||||
// Expanded: VS Code aligns left inset with vertical inset (x = offset + 1).
|
||||
return { x: offset + 1, y: offset };
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { TitleBar } from './TitleBar';
|
||||
import { MAC_SIDEBAR_CHROME_HEIGHT } from '../../../shared/sidebar-layout';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function MainLayout() {
|
||||
@@ -37,7 +38,8 @@ export function MainLayout() {
|
||||
<div
|
||||
data-testid="mac-main-drag-region"
|
||||
aria-hidden="true"
|
||||
className="drag-region absolute inset-x-0 top-0 z-10 h-7"
|
||||
className="drag-region absolute inset-x-0 top-0 z-10"
|
||||
style={{ height: MAC_SIDEBAR_CHROME_HEIGHT }}
|
||||
/>
|
||||
)}
|
||||
<Outlet />
|
||||
|
||||
@@ -37,6 +37,8 @@ import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { SIDEBAR_COLLAPSED_WIDTH, MAC_SIDEBAR_CHROME_HEIGHT } from '../../../shared/sidebar-layout';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import logoSvg from '@/assets/logo.svg';
|
||||
|
||||
@@ -149,6 +151,11 @@ export function Sidebar() {
|
||||
const agents = useAgentsStore((s) => s.agents);
|
||||
const fetchAgents = useAgentsStore((s) => s.fetchAgents);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMac) return;
|
||||
void invokeIpc('window:syncTrafficLightPosition', sidebarCollapsed);
|
||||
}, [isMac, sidebarCollapsed]);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const isOnChat = useLocation().pathname === '/';
|
||||
|
||||
@@ -332,13 +339,21 @@ export function Sidebar() {
|
||||
'relative flex min-h-0 shrink-0 flex-col overflow-hidden bg-surface-sidebar',
|
||||
isResizing ? 'transition-none' : 'transition-[width] duration-300',
|
||||
)}
|
||||
style={{ width: sidebarCollapsed ? 64 : sidebarWidth }}
|
||||
style={{ width: sidebarCollapsed ? SIDEBAR_COLLAPSED_WIDTH : sidebarWidth }}
|
||||
>
|
||||
{isMac && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-testid="mac-sidebar-chrome"
|
||||
className="drag-region shrink-0"
|
||||
style={{ height: MAC_SIDEBAR_CHROME_HEIGHT }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Top Header Toggle */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center p-2 h-12',
|
||||
isMac && 'drag-region h-[4.75rem] items-end pt-10',
|
||||
'flex shrink-0 items-center p-2 h-8',
|
||||
sidebarCollapsed ? 'justify-center' : 'justify-between',
|
||||
)}
|
||||
>
|
||||
@@ -351,6 +366,7 @@ export function Sidebar() {
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
data-testid="sidebar-collapse-toggle"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { expect, test } from './fixtures/electron';
|
||||
import {
|
||||
MAC_SIDEBAR_CHROME_HEIGHT,
|
||||
SIDEBAR_COLLAPSED_WIDTH,
|
||||
} from '../../shared/sidebar-layout';
|
||||
|
||||
test.describe('macOS frameless chrome', () => {
|
||||
test.skip(process.platform !== 'darwin', 'macOS drag-region chrome only');
|
||||
@@ -17,7 +21,19 @@ test.describe('macOS frameless chrome', () => {
|
||||
const box = await mainDragRegion.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.width).toBeGreaterThan(200);
|
||||
expect(box!.height).toBeGreaterThanOrEqual(24);
|
||||
expect(box!.height).toBe(MAC_SIDEBAR_CHROME_HEIGHT);
|
||||
|
||||
const sidebarChrome = page.getByTestId('mac-sidebar-chrome');
|
||||
await expect(sidebarChrome).toBeVisible();
|
||||
await expect(sidebarChrome).toHaveCSS('-webkit-app-region', 'drag');
|
||||
|
||||
const chromeBox = await sidebarChrome.boundingBox();
|
||||
expect(chromeBox).not.toBeNull();
|
||||
expect(chromeBox!.height).toBe(MAC_SIDEBAR_CHROME_HEIGHT);
|
||||
|
||||
const sidebar = page.getByTestId('sidebar');
|
||||
await page.getByTestId('sidebar-collapse-toggle').click();
|
||||
await expect.poll(async () => (await sidebar.boundingBox())?.width ?? 0).toBe(SIDEBAR_COLLAPSED_WIDTH);
|
||||
|
||||
const chatPage = page.getByTestId('chat-page');
|
||||
await expect(chatPage).toBeVisible();
|
||||
|
||||
48
tests/unit/sidebar-layout.test.ts
Normal file
48
tests/unit/sidebar-layout.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getMacTrafficLightChromeOffset,
|
||||
getMacTrafficLightFrameHeight,
|
||||
getMacTrafficLightPosition,
|
||||
MAC_TRAFFIC_LIGHT_FRAME_HEIGHT,
|
||||
MAC_TRAFFIC_LIGHT_GAP,
|
||||
MAC_TRAFFIC_LIGHT_GROUP_WIDTH,
|
||||
MAC_SIDEBAR_CHROME_HEIGHT,
|
||||
SIDEBAR_COLLAPSED_WIDTH,
|
||||
} from '../../shared/sidebar-layout';
|
||||
|
||||
describe('macOS traffic light layout', () => {
|
||||
it('matches VS Code vertical centering in the chrome strip', () => {
|
||||
const offset = getMacTrafficLightChromeOffset(
|
||||
MAC_SIDEBAR_CHROME_HEIGHT,
|
||||
MAC_TRAFFIC_LIGHT_FRAME_HEIGHT,
|
||||
);
|
||||
expect(offset).toBe(6);
|
||||
|
||||
const expanded = getMacTrafficLightPosition({
|
||||
sidebarCollapsed: false,
|
||||
buttonFrameHeight: MAC_TRAFFIC_LIGHT_FRAME_HEIGHT,
|
||||
});
|
||||
expect(expanded).toEqual({ x: 7, y: 6 });
|
||||
});
|
||||
|
||||
it('uses a uniform spacing grid when the sidebar is collapsed', () => {
|
||||
const collapsed = getMacTrafficLightPosition({
|
||||
sidebarCollapsed: true,
|
||||
buttonFrameHeight: MAC_TRAFFIC_LIGHT_FRAME_HEIGHT,
|
||||
});
|
||||
const spacing = MAC_TRAFFIC_LIGHT_GAP;
|
||||
|
||||
expect(collapsed.x).toBe(spacing);
|
||||
expect(collapsed.y).toBeGreaterThanOrEqual(spacing);
|
||||
|
||||
const leftInset = collapsed.x;
|
||||
const rightInset = SIDEBAR_COLLAPSED_WIDTH - (collapsed.x + MAC_TRAFFIC_LIGHT_GROUP_WIDTH);
|
||||
expect(leftInset).toBe(spacing);
|
||||
expect(rightInset).toBe(spacing);
|
||||
});
|
||||
|
||||
it('uses a smaller frame height on Tahoe and newer', () => {
|
||||
expect(getMacTrafficLightFrameHeight(24)).toBe(MAC_TRAFFIC_LIGHT_FRAME_HEIGHT);
|
||||
expect(getMacTrafficLightFrameHeight(25)).toBe(14);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user