feat(ui): add comprehensive quality gates and fix linting issues

- Added ESLint, Prettier, TypeScript strict mode to ui/ directory
- Fixed 40+ lint/format issues across ui codebase
- Created extraction files for react-refresh compliance:
  - button-variants.ts for Button component styles
  - use-sidebar.ts, use-websocket-context.ts custom hooks
  - ws-context.ts WebSocket context provider
- Fixed React hooks issues (setState ordering, dependency arrays)
- Added ui:validate script to enforce quality checks
- Updated husky pre-commit to validate UI files on change
This commit is contained in:
kaitranntt committed 2025-12-07 14:51:29 -05:00
1 parent 59758024c9
commit 707af2f01a
59 files changed
+929 -2835

No files matched your search

+27 -27
View File
@@ -1,53 +1,53 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
interface HealthCheck {
id: string
name: string
status: 'ok' | 'warning' | 'error'
message: string
details?: string
fixable?: boolean
id: string;
name: string;
status: 'ok' | 'warning' | 'error';
message: string;
details?: string;
fixable?: boolean;
}
interface HealthReport {
timestamp: number
checks: HealthCheck[]
timestamp: number;
checks: HealthCheck[];
summary: {
total: number
passed: number
warnings: number
errors: number
}
total: number;
passed: number;
warnings: number;
errors: number;
};
}
export function useHealth() {
return useQuery<HealthReport>({
queryKey: ['health'],
queryFn: async () => {
const res = await fetch('/api/health')
return res.json()
const res = await fetch('/api/health');
return res.json();
},
refetchInterval: 30000, // Auto-refresh every 30s
})
});
}
export function useFixHealth() {
const queryClient = useQueryClient()
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (checkId: string) => {
const res = await fetch(`/api/health/fix/${checkId}`, { method: 'POST' })
const data = await res.json()
if (!res.ok) throw new Error(data.message)
return data
const res = await fetch(`/api/health/fix/${checkId}`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.message);
return data;
},
onSuccess: (data: { message: string }) => {
queryClient.invalidateQueries({ queryKey: ['health'] })
toast.success(data.message)
queryClient.invalidateQueries({ queryKey: ['health'] });
toast.success(data.message);
},
onError: (error: Error) => {
toast.error(error.message)
toast.error(error.message);
},
})
});
}
+11 -11
View File
@@ -1,19 +1,19 @@
import * as React from "react"
import * as React from 'react';
const MOBILE_BREAKPOINT = 768
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener('change', onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener('change', onChange);
}, []);
return !!isMobile
return !!isMobile;
}
+11 -11
View File
@@ -1,22 +1,22 @@
import { useQuery } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query';
interface Overview {
profiles: number
cliproxy: number
accounts: number
profiles: number;
cliproxy: number;
accounts: number;
health: {
status: 'ok' | 'warning' | 'error'
passed: number
total: number
}
status: 'ok' | 'warning' | 'error';
passed: number;
total: number;
};
}
export function useOverview() {
return useQuery<Overview>({
queryKey: ['overview'],
queryFn: async () => {
const res = await fetch('/api/overview')
return res.json()
const res = await fetch('/api/overview');
return res.json();
},
})
});
}
+16 -16
View File
@@ -1,36 +1,36 @@
import { useQuery } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query';
interface SharedItem {
name: string
description: string
path: string
type: 'command' | 'skill' | 'agent'
name: string;
description: string;
path: string;
type: 'command' | 'skill' | 'agent';
}
interface SharedSummary {
commands: number
skills: number
agents: number
total: number
symlinkStatus: { valid: boolean; message: string }
commands: number;
skills: number;
agents: number;
total: number;
symlinkStatus: { valid: boolean; message: string };
}
export function useSharedSummary() {
return useQuery<SharedSummary>({
queryKey: ['shared', 'summary'],
queryFn: async () => {
const res = await fetch('/api/shared/summary')
return res.json()
const res = await fetch('/api/shared/summary');
return res.json();
},
})
});
}
export function useSharedItems(type: 'commands' | 'skills' | 'agents') {
return useQuery<{ items: SharedItem[] }>({
queryKey: ['shared', type],
queryFn: async () => {
const res = await fetch(`/api/shared/${type}`)
return res.json()
const res = await fetch(`/api/shared/${type}`);
return res.json();
},
})
});
}
+24
View File
@@ -0,0 +1,24 @@
import * as React from 'react';
export type SidebarContextProps = {
state: 'expanded' | 'collapsed';
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
export function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider.');
}
return context;
}
export { SidebarContext };
+10 -9
View File
@@ -1,22 +1,23 @@
import { useEffect, useState } from 'react';
function getInitialTheme() {
if (typeof window === 'undefined') return false;
const stored = localStorage.getItem('ccs-theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
return stored === 'dark' || (!stored && prefersDark);
}
export function useTheme() {
const [isDark, setIsDark] = useState(false);
const [isDark, setIsDark] = useState(getInitialTheme);
useEffect(() => {
const stored = localStorage.getItem('ccs-theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDarkMode = stored === 'dark' || (!stored && prefersDark);
setIsDark(isDarkMode);
document.documentElement.classList.toggle('dark', isDarkMode);
}, []);
document.documentElement.classList.toggle('dark', isDark);
}, [isDark]);
const toggle = () => {
const newValue = !isDark;
setIsDark(newValue);
localStorage.setItem('ccs-theme', newValue ? 'dark' : 'light');
document.documentElement.classList.toggle('dark', newValue);
};
return { isDark, toggle };
+10
View File
@@ -0,0 +1,10 @@
import { useContext } from 'react';
import { WSContext } from '@/contexts/ws-context';
export function useWebSocketContext() {
const context = useContext(WSContext);
if (!context) {
throw new Error('useWebSocketContext must be used within WebSocketProvider');
}
return context;
}
+51 -35
View File
@@ -22,6 +22,42 @@ export function useWebSocket() {
const queryClient = useQueryClient();
const reconnectAttempts = useRef(0);
const maxReconnectAttempts = 5;
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const connectRef = useRef<() => void>(() => {});
const handleMessage = useCallback(
(message: WSMessage) => {
switch (message.type) {
case 'connected':
console.log('[WS] Server acknowledged connection');
break;
case 'config-changed':
queryClient.invalidateQueries({ queryKey: ['profiles'] });
queryClient.invalidateQueries({ queryKey: ['cliproxy'] });
toast.info('Configuration updated externally');
break;
case 'settings-changed':
queryClient.invalidateQueries({ queryKey: ['profiles'] });
toast.info('Settings file updated');
break;
case 'profiles-changed':
queryClient.invalidateQueries({ queryKey: ['accounts'] });
toast.info('Accounts updated');
break;
case 'pong':
// Heartbeat response
break;
default:
console.log(`[WS] Unknown message: ${message.type}`);
}
},
[queryClient]
);
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
@@ -56,56 +92,36 @@ export function useWebSocket() {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000);
reconnectAttempts.current++;
console.log(`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttempts.current})`);
setTimeout(connect, delay);
// Use ref to avoid stale closure
reconnectTimeoutRef.current = setTimeout(() => {
connectRef.current();
}, delay);
}
};
ws.onerror = () => {
console.log('[WS] Connection error');
};
}, []);
}, [handleMessage]);
const handleMessage = (message: WSMessage) => {
switch (message.type) {
case 'connected':
console.log('[WS] Server acknowledged connection');
break;
case 'config-changed':
queryClient.invalidateQueries({ queryKey: ['profiles'] });
queryClient.invalidateQueries({ queryKey: ['cliproxy'] });
toast.info('Configuration updated externally');
break;
case 'settings-changed':
queryClient.invalidateQueries({ queryKey: ['profiles'] });
toast.info('Settings file updated');
break;
case 'profiles-changed':
queryClient.invalidateQueries({ queryKey: ['accounts'] });
toast.info('Accounts updated');
break;
case 'pong':
// Heartbeat response
break;
default:
console.log(`[WS] Unknown message: ${message.type}`);
}
};
// Keep ref in sync
connectRef.current = connect;
const disconnect = useCallback(() => {
reconnectAttempts.current = maxReconnectAttempts; // Prevent reconnect
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
wsRef.current?.close();
}, []);
// Connect on mount, disconnect on unmount
// Initial connection - use ref to satisfy linter
useEffect(() => {
connect();
connectRef.current();
return () => disconnect();
}, [connect, disconnect]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Heartbeat to keep connection alive
useEffect(() => {