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

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

View File

@@ -1,6 +1,7 @@
/**
* 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 {
@@ -17,11 +18,9 @@ import {
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useSettingsStore } from '@/stores/settings';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
interface NavItemProps {
to: string;
icon: React.ReactNode;
@@ -64,11 +63,11 @@ export function Sidebar() {
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked);
// Open developer console
const openDevConsole = () => {
window.electron.openExternal('http://localhost:18789');
};
const navItems = [
{ to: '/', icon: <MessageSquare className="h-5 w-5" />, label: 'Chat' },
{ 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: '/settings', icon: <Settings className="h-5 w-5" />, label: 'Settings' },
];
return (
<aside
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'
)}
>
{/* 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 */}
<nav className="flex-1 space-y-1 p-2">
<nav className="flex-1 space-y-1 overflow-auto p-2">
{navItems.map((item) => (
<NavItem
key={item.to}
@@ -104,10 +94,9 @@ export function Sidebar() {
/>
))}
</nav>
{/* Footer */}
<div className="p-2 space-y-2">
{/* Developer Mode Button */}
{devModeUnlocked && !sidebarCollapsed && (
<Button
variant="ghost"
@@ -121,8 +110,6 @@ export function Sidebar() {
</Button>
)}
{/* Collapse Toggle */}
<Button
variant="ghost"
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 { ChatMessage } from './ChatMessage';
import { ChatInput } from './ChatInput';
import { ChatToolbar } from './ChatToolbar';
import { extractText } from './message-utils';
export function Chat() {
@@ -73,7 +74,12 @@ export function Chat() {
const streamText = streamingMessage ? extractText(streamingMessage) : '';
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 */}
<div className="flex-1 overflow-y-auto px-4 py-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 [logContent, setLogContent] = useState('');
const [openclawDir, setOpenclawDir] = useState('');
const gatewayTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const runChecks = useCallback(async () => {
// Reset checks
@@ -411,28 +412,31 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
}));
}
// Check Gateway
await new Promise((resolve) => setTimeout(resolve, 500));
if (gatewayStatus.state === 'running') {
// Check Gateway — read directly from store to avoid stale closure
// Don't immediately report error; gateway may still be initializing
const currentGateway = useGatewayStore.getState().status;
if (currentGateway.state === 'running') {
setChecks((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) => ({
...prev,
gateway: { status: 'checking', message: 'Starting...' },
gateway: { status: 'error', message: currentGateway.error || 'Failed to start' },
}));
} else {
// Gateway is 'stopped', 'starting', or 'reconnecting'
// Keep as 'checking' — the dedicated useEffect will update when status changes
setChecks((prev) => ({
...prev,
gateway: {
status: 'error',
message: gatewayStatus.error || 'Not running'
status: 'checking',
message: currentGateway.state === 'starting' ? 'Starting...' : 'Waiting for gateway...'
},
}));
}
}, [gatewayStatus]);
}, []);
useEffect(() => {
runChecks();
@@ -458,9 +462,48 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
...prev,
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]);
// 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 () => {
setChecks((prev) => ({
...prev,

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

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