feat(style): refactor layout, remove Header & Improve gateway readiness checks (#12)
This commit is contained in:
committed by
GitHub
Unverified
parent
86ddd843c4
commit
05b5874832
3
.gitignore
vendored
3
.gitignore
vendored
@@ -60,4 +60,5 @@ resources/bin
|
|||||||
|
|
||||||
build/
|
build/
|
||||||
|
|
||||||
.cursor/
|
.cursor/
|
||||||
|
.pnpm-store/
|
||||||
@@ -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})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
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
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 |
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,11 +63,11 @@ 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');
|
||||||
};
|
};
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/', icon: <MessageSquare className="h-5 w-5" />, label: 'Chat' },
|
{ to: '/', icon: <MessageSquare className="h-5 w-5" />, label: 'Chat' },
|
||||||
{ to: '/cron', icon: <Clock className="h-5 w-5" />, label: 'Cron Tasks' },
|
{ to: '/cron', icon: <Clock className="h-5 w-5" />, label: 'Cron Tasks' },
|
||||||
@@ -77,25 +76,16 @@ export function Sidebar() {
|
|||||||
{ to: '/dashboard', icon: <Home className="h-5 w-5" />, label: 'Dashboard' },
|
{ to: '/dashboard', icon: <Home className="h-5 w-5" />, label: 'Dashboard' },
|
||||||
{ to: '/settings', icon: <Settings className="h-5 w-5" />, label: 'Settings' },
|
{ to: '/settings', icon: <Settings className="h-5 w-5" />, label: 'Settings' },
|
||||||
];
|
];
|
||||||
|
|
||||||
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}
|
||||||
@@ -104,10 +94,9 @@ export function Sidebar() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* 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"
|
||||||
|
|||||||
83
src/components/layout/TitleBar.tsx
Normal file
83
src/components/layout/TitleBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
Reference in New Issue
Block a user