feat(ui): improve settings page UX and responsiveness

- Add mobile-responsive layout for settings panels

- Add empty state UI for websearch providers

- Improve connection indicator with WebSocket status
This commit is contained in:
kaitranntt
2026-02-03 22:33:31 -05:00
parent cb8de2c8e8
commit 4d87a649de
4 changed files with 61 additions and 9 deletions
@@ -1,19 +1,29 @@
/** /**
* Connection Indicator (Phase 04) * Connection Indicator (Phase 04)
* *
* Shows WebSocket connection status in the header. * Shows WebSocket connection status in the header with reconnection state.
*/ */
import { Wifi, WifiOff } from 'lucide-react'; import { Wifi, WifiOff, RefreshCw } from 'lucide-react';
import { useWebSocket } from '@/hooks/use-websocket'; import { useWebSocket } from '@/hooks/use-websocket';
export function ConnectionIndicator() { export function ConnectionIndicator() {
const { status } = useWebSocket(); const { status, isReconnecting } = useWebSocket();
const statusConfig = { const statusConfig = {
connected: { icon: Wifi, color: 'text-green-600', label: 'Connected' }, connected: { icon: Wifi, color: 'text-green-600', label: 'Connected' },
connecting: { icon: Wifi, color: 'text-yellow-500', label: 'Connecting...' }, connecting: {
disconnected: { icon: WifiOff, color: 'text-red-500', label: 'Disconnected' }, icon: RefreshCw,
color: 'text-yellow-500',
label: 'Connecting...',
animate: true,
},
disconnected: {
icon: isReconnecting ? RefreshCw : WifiOff,
color: isReconnecting ? 'text-amber-500' : 'text-red-500',
label: isReconnecting ? 'Reconnecting...' : 'Disconnected',
animate: isReconnecting,
},
}; };
const config = statusConfig[status]; const config = statusConfig[status];
@@ -21,7 +31,7 @@ export function ConnectionIndicator() {
return ( return (
<div className={`flex items-center gap-1 text-sm ${config.color}`}> <div className={`flex items-center gap-1 text-sm ${config.color}`}>
<Icon className="w-4 h-4" /> <Icon className={`w-4 h-4 ${config.animate ? 'animate-spin' : ''}`} />
<span className="hidden sm:inline">{config.label}</span> <span className="hidden sm:inline">{config.label}</span>
</div> </div>
); );
+10 -1
View File
@@ -18,6 +18,7 @@ type ConnectionStatus = 'connecting' | 'connected' | 'disconnected';
export function useWebSocket() { export function useWebSocket() {
const [status, setStatus] = useState<ConnectionStatus>('disconnected'); const [status, setStatus] = useState<ConnectionStatus>('disconnected');
const [isReconnecting, setIsReconnecting] = useState(false);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const reconnectAttempts = useRef(0); const reconnectAttempts = useRef(0);
@@ -75,6 +76,7 @@ export function useWebSocket() {
ws.onopen = () => { ws.onopen = () => {
setStatus('connected'); setStatus('connected');
setIsReconnecting(false);
reconnectAttempts.current = 0; reconnectAttempts.current = 0;
console.log('[WS] Connected'); console.log('[WS] Connected');
}; };
@@ -97,6 +99,7 @@ export function useWebSocket() {
// Attempt reconnect with exponential backoff // Attempt reconnect with exponential backoff
if (reconnectAttempts.current < maxReconnectAttempts) { if (reconnectAttempts.current < maxReconnectAttempts) {
setIsReconnecting(true);
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000); const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000);
reconnectAttempts.current++; reconnectAttempts.current++;
console.log(`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttempts.current})`); console.log(`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttempts.current})`);
@@ -104,6 +107,8 @@ export function useWebSocket() {
reconnectTimeoutRef.current = setTimeout(() => { reconnectTimeoutRef.current = setTimeout(() => {
connectRef.current(); connectRef.current();
}, delay); }, delay);
} else {
setIsReconnecting(false);
} }
}; };
@@ -117,6 +122,7 @@ export function useWebSocket() {
const disconnect = useCallback(() => { const disconnect = useCallback(() => {
reconnectAttempts.current = maxReconnectAttempts; // Prevent reconnect reconnectAttempts.current = maxReconnectAttempts; // Prevent reconnect
setIsReconnecting(false);
if (reconnectTimeoutRef.current) { if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current); clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null; reconnectTimeoutRef.current = null;
@@ -142,5 +148,8 @@ export function useWebSocket() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);
return useMemo(() => ({ status, connect, disconnect }), [status, connect, disconnect]); return useMemo(
() => ({ status, isReconnecting, connect, disconnect }),
[status, isReconnecting, connect, disconnect]
);
} }
+19 -1
View File
@@ -115,7 +115,25 @@ function SettingsPageInner() {
return ( return (
<div className="h-[calc(100vh-100px)]"> <div className="h-[calc(100vh-100px)]">
<PanelGroup direction="horizontal" className="h-full"> {/* Mobile View - Stacked vertically */}
<div className="md:hidden h-full flex flex-col">
<div className="border-b bg-background p-4">
<TabNavigation activeTab={activeTab} onTabChange={handleTabChange} />
</div>
<SectionErrorBoundary>
<Suspense fallback={<SectionSkeleton />}>
{activeTab === 'websearch' && <WebSearchSection />}
{activeTab === 'globalenv' && <GlobalEnvSection />}
{activeTab === 'thinking' && <ThinkingSection />}
{activeTab === 'proxy' && <ProxySection />}
{activeTab === 'auth' && <AuthSection />}
{activeTab === 'backups' && <BackupsSection />}
</Suspense>
</SectionErrorBoundary>
</div>
{/* Desktop View - Side-by-side panels */}
<PanelGroup direction="horizontal" className="h-full hidden md:flex">
{/* Left Panel - Settings Controls */} {/* Left Panel - Settings Controls */}
<Panel defaultSize={40} minSize={30} maxSize={55}> <Panel defaultSize={40} minSize={30} maxSize={55}>
<div className="h-full border-r flex flex-col bg-muted/30 relative"> <div className="h-full border-r flex flex-col bg-muted/30 relative">
@@ -7,7 +7,7 @@ import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { RefreshCw, CheckCircle2, AlertCircle } from 'lucide-react'; import { RefreshCw, CheckCircle2, AlertCircle, Package } from 'lucide-react';
import { useWebSearchConfig, useRawConfig } from '../../hooks'; import { useWebSearchConfig, useRawConfig } from '../../hooks';
import { ProviderCard } from './provider-card'; import { ProviderCard } from './provider-card';
@@ -141,6 +141,21 @@ export default function WebSearchSection() {
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-base font-medium">Providers</h3> <h3 className="text-base font-medium">Providers</h3>
{/* Empty state when no providers available */}
{!status?.geminiCli && !status?.opencodeCli && !status?.grokCli && !statusLoading && (
<div className="flex flex-col items-center justify-center p-8 border-2 border-dashed rounded-lg text-center bg-muted/30">
<Package className="w-12 h-12 text-muted-foreground mb-3 opacity-30" />
<p className="font-medium text-foreground mb-1">No providers configured</p>
<p className="text-sm text-muted-foreground mb-4">
Install CLI tools to enable web search providers
</p>
<Button variant="outline" size="sm" onClick={fetchStatus}>
<RefreshCw className="w-4 h-4 mr-2" />
Check for providers
</Button>
</div>
)}
<ProviderCard <ProviderCard
name="gemini" name="gemini"
label="Google Gemini CLI (1000 req/day free)" label="Google Gemini CLI (1000 req/day free)"