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
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
|
||||
* 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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
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 { 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">
|
||||
|
||||
@@ -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
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user