feat(style): refactor layout, remove Header & Improve gateway readiness checks (#12)

This commit is contained in:
DigHuang
2026-02-09 01:00:27 -08:00
committed by GitHub
Unverified
parent 86ddd843c4
commit 05b5874832
14 changed files with 217 additions and 132 deletions

1
.gitignore vendored
View File

@@ -61,3 +61,4 @@ resources/bin
build/ build/
.cursor/ .cursor/
.pnpm-store/

View File

@@ -504,15 +504,22 @@ export class GatewayManager extends EventEmitter {
/** /**
* Wait for Gateway to be ready by checking if the port is accepting connections * Wait for Gateway to be ready by checking if the port is accepting connections
*/ */
private async waitForReady(retries = 30, interval = 1000): Promise<void> { private async waitForReady(retries = 120, interval = 1000): Promise<void> {
for (let i = 0; i < retries; i++) { for (let i = 0; i < retries; i++) {
// Early exit if the gateway process has already exited
if (this.process && this.process.exitCode !== null) {
const code = this.process.exitCode;
logger.error(`Gateway process exited with code ${code} before becoming ready`);
throw new Error(`Gateway process exited with code ${code} before becoming ready`);
}
try { try {
const ready = await new Promise<boolean>((resolve) => { const ready = await new Promise<boolean>((resolve) => {
const testWs = new WebSocket(`ws://localhost:${this.status.port}/ws`); const testWs = new WebSocket(`ws://localhost:${this.status.port}/ws`);
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
testWs.close(); testWs.close();
resolve(false); resolve(false);
}, 1000); }, 2000);
testWs.on('open', () => { testWs.on('open', () => {
clearTimeout(timeout); clearTimeout(timeout);
@@ -534,7 +541,7 @@ export class GatewayManager extends EventEmitter {
// Gateway not ready yet // Gateway not ready yet
} }
if (i > 0 && i % 5 === 0) { if (i > 0 && i % 10 === 0) {
logger.info(`Still waiting for Gateway... (attempt ${i + 1}/${retries})`); logger.info(`Still waiting for Gateway... (attempt ${i + 1}/${retries})`);
} }

View File

@@ -12,11 +12,11 @@ import { createMenu } from './menu';
import { appUpdater, registerUpdateHandlers } from './updater'; import { appUpdater, registerUpdateHandlers } from './updater';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { ClawHubService } from '../gateway/clawhub';
// Disable GPU acceleration for better compatibility // Disable GPU acceleration for better compatibility
app.disableHardwareAcceleration(); app.disableHardwareAcceleration();
import { ClawHubService } from '../gateway/clawhub';
// Global references // Global references
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
const gatewayManager = new GatewayManager(); const gatewayManager = new GatewayManager();
@@ -26,6 +26,8 @@ const clawHubService = new ClawHubService();
* Create the main application window * Create the main application window
*/ */
function createWindow(): BrowserWindow { function createWindow(): BrowserWindow {
const isMac = process.platform === 'darwin';
const win = new BrowserWindow({ const win = new BrowserWindow({
width: 1280, width: 1280,
height: 800, height: 800,
@@ -38,8 +40,9 @@ function createWindow(): BrowserWindow {
sandbox: false, sandbox: false,
webviewTag: true, // Enable <webview> for embedding OpenClaw Control UI webviewTag: true, // Enable <webview> for embedding OpenClaw Control UI
}, },
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', titleBarStyle: isMac ? 'hiddenInset' : 'hidden',
trafficLightPosition: { x: 16, y: 16 }, trafficLightPosition: isMac ? { x: 16, y: 16 } : undefined,
frame: isMac,
show: false, show: false,
}); });
@@ -57,7 +60,6 @@ function createWindow(): BrowserWindow {
// Load the app // Load the app
if (process.env.VITE_DEV_SERVER_URL) { if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(process.env.VITE_DEV_SERVER_URL); win.loadURL(process.env.VITE_DEV_SERVER_URL);
// Open DevTools in development
win.webContents.openDevTools(); win.webContents.openDevTools();
} else { } else {
win.loadFile(join(__dirname, '../../dist/index.html')); win.loadFile(join(__dirname, '../../dist/index.html'));
@@ -91,8 +93,6 @@ async function initialize(): Promise<void> {
createTray(mainWindow); createTray(mainWindow);
// Override security headers ONLY for the OpenClaw Gateway Control UI // Override security headers ONLY for the OpenClaw Gateway Control UI
// The Control UI sets X-Frame-Options: DENY and CSP frame-ancestors 'none'
// which prevents embedding in an iframe. Only apply to gateway URLs.
session.defaultSession.webRequest.onHeadersReceived((details, callback) => { session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
const isGatewayUrl = details.url.includes('127.0.0.1:18789') || details.url.includes('localhost:18789'); const isGatewayUrl = details.url.includes('127.0.0.1:18789') || details.url.includes('localhost:18789');
@@ -102,10 +102,8 @@ async function initialize(): Promise<void> {
} }
const headers = { ...details.responseHeaders }; const headers = { ...details.responseHeaders };
// Remove X-Frame-Options to allow embedding in iframe
delete headers['X-Frame-Options']; delete headers['X-Frame-Options'];
delete headers['x-frame-options']; delete headers['x-frame-options'];
// Remove restrictive CSP frame-ancestors
if (headers['Content-Security-Policy']) { if (headers['Content-Security-Policy']) {
headers['Content-Security-Policy'] = headers['Content-Security-Policy'].map( headers['Content-Security-Policy'] = headers['Content-Security-Policy'].map(
(csp) => csp.replace(/frame-ancestors\s+'none'/g, "frame-ancestors 'self' *") (csp) => csp.replace(/frame-ancestors\s+'none'/g, "frame-ancestors 'self' *")
@@ -131,7 +129,7 @@ async function initialize(): Promise<void> {
appUpdater.checkForUpdates().catch((err) => { appUpdater.checkForUpdates().catch((err) => {
console.error('Failed to check for updates:', err); console.error('Failed to check for updates:', err);
}); });
}, 10000); // Check after 10 seconds }, 10000);
} }
// Handle window close // Handle window close
@@ -139,14 +137,13 @@ async function initialize(): Promise<void> {
mainWindow = null; mainWindow = null;
}); });
// Start Gateway automatically (optional based on settings) // Start Gateway automatically
try { try {
logger.info('Auto-starting Gateway...'); logger.info('Auto-starting Gateway...');
await gatewayManager.start(); await gatewayManager.start();
logger.info('Gateway auto-start succeeded'); logger.info('Gateway auto-start succeeded');
} catch (error) { } catch (error) {
logger.error('Gateway auto-start failed:', error); logger.error('Gateway auto-start failed:', error);
// Notify renderer about the error
mainWindow?.webContents.send('gateway:error', String(error)); mainWindow?.webContents.send('gateway:error', String(error));
} }
} }
@@ -155,21 +152,18 @@ async function initialize(): Promise<void> {
app.whenReady().then(initialize); app.whenReady().then(initialize);
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
// On macOS, keep the app running in the menu bar
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
app.quit(); app.quit();
} }
}); });
app.on('activate', () => { app.on('activate', () => {
// On macOS, re-create window when dock icon is clicked
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createWindow(); mainWindow = createWindow();
} }
}); });
app.on('before-quit', async () => { app.on('before-quit', async () => {
// Clean up Gateway process
await gatewayManager.stop(); await gatewayManager.stop();
}); });

View File

@@ -77,6 +77,9 @@ export function registerIpcHandlers(
// Cron task handlers (proxy to Gateway RPC) // Cron task handlers (proxy to Gateway RPC)
registerCronHandlers(gatewayManager); registerCronHandlers(gatewayManager);
// Window control handlers (for custom title bar on Windows/Linux)
registerWindowHandlers(mainWindow);
} }
/** /**
@@ -1151,3 +1154,28 @@ function registerAppHandlers(): void {
app.quit(); app.quit();
}); });
} }
/**
* Window control handlers (for custom title bar on Windows/Linux)
*/
function registerWindowHandlers(mainWindow: BrowserWindow): void {
ipcMain.handle('window:minimize', () => {
mainWindow.minimize();
});
ipcMain.handle('window:maximize', () => {
if (mainWindow.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow.maximize();
}
});
ipcMain.handle('window:close', () => {
mainWindow.close();
});
ipcMain.handle('window:isMaximized', () => {
return mainWindow.isMaximized();
});
}

View File

@@ -41,6 +41,11 @@ const electronAPI = {
'app:platform', 'app:platform',
'app:quit', 'app:quit',
'app:relaunch', 'app:relaunch',
// Window controls
'window:minimize',
'window:maximize',
'window:close',
'window:isMaximized',
// Settings // Settings
'settings:get', 'settings:get',
'settings:set', 'settings:set',

BIN
public/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

3
src/assets/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@@ -1,61 +0,0 @@
/**
* Header Component
* Top navigation bar with page title and page-specific controls.
* On the Chat page, shows session selector, refresh, thinking toggle, and new session.
*/
import { useLocation } from 'react-router-dom';
import { Terminal } from 'lucide-react';
import { ChatToolbar } from '@/pages/Chat/ChatToolbar';
import { Button } from '@/components/ui/button';
// Page titles mapping
const pageTitles: Record<string, string> = {
'/': 'Chat',
'/dashboard': 'Dashboard',
'/channels': 'Channels',
'/skills': 'Skills',
'/cron': 'Cron Tasks',
'/settings': 'Settings',
};
export function Header() {
const location = useLocation();
const currentTitle = pageTitles[location.pathname] || 'ClawX';
const isChatPage = location.pathname === '/';
const isDashboard = location.pathname === '/dashboard';
const handleOpenDevConsole = async () => {
try {
const result = await window.electron.ipcRenderer.invoke('gateway:getControlUiUrl') as { success: boolean; url?: string; error?: string };
if (result.success && result.url) {
window.electron.openExternal(result.url);
} else {
console.error('Failed to get Dev Console URL:', result.error);
}
} catch (err) {
console.error('Error opening Dev Console:', err);
}
};
return (
<header className="flex h-14 items-center justify-between border-b bg-background px-6">
<h2 className="text-lg font-semibold">{currentTitle}</h2>
{/* Chat-specific controls */}
{isChatPage && <ChatToolbar />}
{/* Dashboard specific controls - Dev Console Button */}
{isDashboard && (
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 rounded-full border border-neutral-200 px-3 text-xs font-normal text-neutral-500 hover:bg-neutral-50 hover:text-neutral-700 dark:border-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-900 dark:hover:text-neutral-200"
onClick={handleOpenDevConsole}
>
<Terminal className="h-3.5 w-3.5" />
Gateway
</Button>
)}
</header>
);
}

View File

@@ -1,32 +1,20 @@
/** /**
* Main Layout Component * Main Layout Component
* Provides the primary app layout with sidebar and content area * TitleBar at top, then sidebar + content below.
*/ */
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { Header } from './Header'; import { TitleBar } from './TitleBar';
import { useSettingsStore } from '@/stores/settings';
import { cn } from '@/lib/utils';
export function MainLayout() { export function MainLayout() {
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
return ( return (
<div className="flex h-screen overflow-hidden bg-background"> <div className="flex h-screen flex-col overflow-hidden bg-background">
{/* Sidebar */} {/* Title bar: drag region on macOS, icon + controls on Windows */}
<Sidebar /> <TitleBar />
{/* Main Content Area */} {/* Below the title bar: sidebar + content */}
<div <div className="flex flex-1 overflow-hidden">
className={cn( <Sidebar />
'flex flex-1 flex-col overflow-hidden transition-all duration-300',
sidebarCollapsed ? 'ml-16' : 'ml-64'
)}
>
{/* Header */}
<Header />
{/* Page Content */}
<main className="flex-1 overflow-auto p-6"> <main className="flex-1 overflow-auto p-6">
<Outlet /> <Outlet />
</main> </main>

View File

@@ -1,6 +1,7 @@
/** /**
* Sidebar Component * Sidebar Component
* Navigation sidebar with menu items * Navigation sidebar with menu items.
* No longer fixed - sits inside the flex layout below the title bar.
*/ */
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { import {
@@ -17,11 +18,9 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useSettingsStore } from '@/stores/settings'; import { useSettingsStore } from '@/stores/settings';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
interface NavItemProps { interface NavItemProps {
to: string; to: string;
icon: React.ReactNode; icon: React.ReactNode;
@@ -64,7 +63,7 @@ export function Sidebar() {
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed); const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed); const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked); const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked);
// Open developer console
const openDevConsole = () => { const openDevConsole = () => {
window.electron.openExternal('http://localhost:18789'); window.electron.openExternal('http://localhost:18789');
}; };
@@ -81,21 +80,12 @@ export function Sidebar() {
return ( return (
<aside <aside
className={cn( className={cn(
'fixed left-0 top-0 z-40 flex h-screen flex-col border-r bg-background transition-all duration-300', 'flex shrink-0 flex-col border-r bg-background transition-all duration-300',
sidebarCollapsed ? 'w-16' : 'w-64' sidebarCollapsed ? 'w-16' : 'w-64'
)} )}
> >
{/* Header with drag region for macOS */}
<div className="drag-region flex h-14 items-center border-b px-4">
{/* macOS traffic light spacing */}
<div className="w-16" />
{!sidebarCollapsed && (
<h1 className="no-drag text-xl font-bold">ClawX</h1>
)}
</div>
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 space-y-1 p-2"> <nav className="flex-1 space-y-1 overflow-auto p-2">
{navItems.map((item) => ( {navItems.map((item) => (
<NavItem <NavItem
key={item.to} key={item.to}
@@ -107,7 +97,6 @@ export function Sidebar() {
{/* Footer */} {/* Footer */}
<div className="p-2 space-y-2"> <div className="p-2 space-y-2">
{/* Developer Mode Button */}
{devModeUnlocked && !sidebarCollapsed && ( {devModeUnlocked && !sidebarCollapsed && (
<Button <Button
variant="ghost" variant="ghost"
@@ -121,8 +110,6 @@ export function Sidebar() {
</Button> </Button>
)} )}
{/* Collapse Toggle */}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"

View File

@@ -0,0 +1,83 @@
/**
* TitleBar Component
* macOS: empty drag region (native traffic lights handled by hiddenInset).
* Windows/Linux: icon + "ClawX" on left, minimize/maximize/close on right.
*/
import { useState, useEffect } from 'react';
import { Minus, Square, X, Copy } from 'lucide-react';
import logoSvg from '@/assets/logo.svg';
const isMac = window.electron?.platform === 'darwin';
export function TitleBar() {
if (isMac) {
// macOS: just a drag region, traffic lights are native
return <div className="drag-region h-10 shrink-0 border-b bg-background" />;
}
return <WindowsTitleBar />;
}
function WindowsTitleBar() {
const [maximized, setMaximized] = useState(false);
useEffect(() => {
// Check initial state
window.electron.ipcRenderer.invoke('window:isMaximized').then((val) => {
setMaximized(val as boolean);
});
}, []);
const handleMinimize = () => {
window.electron.ipcRenderer.invoke('window:minimize');
};
const handleMaximize = () => {
window.electron.ipcRenderer.invoke('window:maximize').then(() => {
window.electron.ipcRenderer.invoke('window:isMaximized').then((val) => {
setMaximized(val as boolean);
});
});
};
const handleClose = () => {
window.electron.ipcRenderer.invoke('window:close');
};
return (
<div className="drag-region flex h-10 shrink-0 items-center justify-between border-b bg-background">
{/* Left: Icon + App Name */}
<div className="no-drag flex items-center gap-2 pl-3">
<img src={logoSvg} alt="ClawX" className="h-5 w-auto" />
<span className="text-xs font-medium text-muted-foreground select-none">
ClawX
</span>
</div>
{/* Right: Window Controls */}
<div className="no-drag flex h-full">
<button
onClick={handleMinimize}
className="flex h-full w-11 items-center justify-center text-muted-foreground hover:bg-accent transition-colors"
title="Minimize"
>
<Minus className="h-4 w-4" />
</button>
<button
onClick={handleMaximize}
className="flex h-full w-11 items-center justify-center text-muted-foreground hover:bg-accent transition-colors"
title={maximized ? 'Restore' : 'Maximize'}
>
{maximized ? <Copy className="h-3.5 w-3.5" /> : <Square className="h-3.5 w-3.5" />}
</button>
<button
onClick={handleClose}
className="flex h-full w-11 items-center justify-center text-muted-foreground hover:bg-red-500 hover:text-white transition-colors"
title="Close"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
);
}

View File

@@ -12,6 +12,7 @@ import { useGatewayStore } from '@/stores/gateway';
import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { ChatMessage } from './ChatMessage'; import { ChatMessage } from './ChatMessage';
import { ChatInput } from './ChatInput'; import { ChatInput } from './ChatInput';
import { ChatToolbar } from './ChatToolbar';
import { extractText } from './message-utils'; import { extractText } from './message-utils';
export function Chat() { export function Chat() {
@@ -73,7 +74,12 @@ export function Chat() {
const streamText = streamingMessage ? extractText(streamingMessage) : ''; const streamText = streamingMessage ? extractText(streamingMessage) : '';
return ( return (
<div className="flex flex-col -m-6" style={{ height: 'calc(100vh - 3.5rem)' }}> <div className="flex flex-col -m-6" style={{ height: 'calc(100vh - 2.5rem)' }}>
{/* Toolbar */}
<div className="flex shrink-0 items-center justify-end px-4 py-2">
<ChatToolbar />
</div>
{/* Messages Area */} {/* Messages Area */}
<div className="flex-1 overflow-y-auto px-4 py-4"> <div className="flex-1 overflow-y-auto px-4 py-4">
<div className="max-w-4xl mx-auto space-y-4"> <div className="max-w-4xl mx-auto space-y-4">

View File

@@ -352,6 +352,7 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
const [showLogs, setShowLogs] = useState(false); const [showLogs, setShowLogs] = useState(false);
const [logContent, setLogContent] = useState(''); const [logContent, setLogContent] = useState('');
const [openclawDir, setOpenclawDir] = useState(''); const [openclawDir, setOpenclawDir] = useState('');
const gatewayTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const runChecks = useCallback(async () => { const runChecks = useCallback(async () => {
// Reset checks // Reset checks
@@ -411,28 +412,31 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
})); }));
} }
// Check Gateway // Check Gateway — read directly from store to avoid stale closure
await new Promise((resolve) => setTimeout(resolve, 500)); // Don't immediately report error; gateway may still be initializing
if (gatewayStatus.state === 'running') { const currentGateway = useGatewayStore.getState().status;
if (currentGateway.state === 'running') {
setChecks((prev) => ({ setChecks((prev) => ({
...prev, ...prev,
gateway: { status: 'success', message: `Running on port ${gatewayStatus.port}` }, gateway: { status: 'success', message: `Running on port ${currentGateway.port}` },
})); }));
} else if (gatewayStatus.state === 'starting') { } else if (currentGateway.state === 'error') {
setChecks((prev) => ({ setChecks((prev) => ({
...prev, ...prev,
gateway: { status: 'checking', message: 'Starting...' }, gateway: { status: 'error', message: currentGateway.error || 'Failed to start' },
})); }));
} else { } else {
// Gateway is 'stopped', 'starting', or 'reconnecting'
// Keep as 'checking' — the dedicated useEffect will update when status changes
setChecks((prev) => ({ setChecks((prev) => ({
...prev, ...prev,
gateway: { gateway: {
status: 'error', status: 'checking',
message: gatewayStatus.error || 'Not running' message: currentGateway.state === 'starting' ? 'Starting...' : 'Waiting for gateway...'
}, },
})); }));
} }
}, [gatewayStatus]); }, []);
useEffect(() => { useEffect(() => {
runChecks(); runChecks();
@@ -458,9 +462,48 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
...prev, ...prev,
gateway: { status: 'error', message: gatewayStatus.error || 'Failed to start' }, gateway: { status: 'error', message: gatewayStatus.error || 'Failed to start' },
})); }));
} else if (gatewayStatus.state === 'starting' || gatewayStatus.state === 'reconnecting') {
setChecks((prev) => ({
...prev,
gateway: { status: 'checking', message: 'Starting...' },
}));
} }
// 'stopped' state: keep current check status (likely 'checking') to allow startup time
}, [gatewayStatus]); }, [gatewayStatus]);
// Gateway startup timeout — show error only after giving enough time to initialize
useEffect(() => {
if (gatewayTimeoutRef.current) {
clearTimeout(gatewayTimeoutRef.current);
gatewayTimeoutRef.current = null;
}
// If gateway is already in a terminal state, no timeout needed
if (gatewayStatus.state === 'running' || gatewayStatus.state === 'error') {
return;
}
// Set timeout for non-terminal states (stopped, starting, reconnecting)
gatewayTimeoutRef.current = setTimeout(() => {
setChecks((prev) => {
if (prev.gateway.status === 'checking') {
return {
...prev,
gateway: { status: 'error', message: 'Gateway startup timed out' },
};
}
return prev;
});
}, 120 * 1000); // 120 seconds — enough for gateway to fully initialize
return () => {
if (gatewayTimeoutRef.current) {
clearTimeout(gatewayTimeoutRef.current);
gatewayTimeoutRef.current = null;
}
};
}, [gatewayStatus.state]);
const handleStartGateway = async () => { const handleStartGateway = async () => {
setChecks((prev) => ({ setChecks((prev) => ({
...prev, ...prev,

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />