- CLAUDE.md for AI agents to understand the codebase - GITEA-GUIDE.md centralizes all Gitea operations (API, Registry, Auth) - DEVELOPMENT-WORKFLOW.md explains complete dev process - ROADMAP.md, NEXT-SESSION.md for planning - QUICK-REFERENCE.md, TROUBLESHOOTING.md for daily use - 40+ detailed docs in /docs folder - Backend as submodule from Gitea Everything documented for autonomous operation. Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
505 lines
12 KiB
Markdown
505 lines
12 KiB
Markdown
# Gestión de Estado
|
|
|
|
## React Query para Server State
|
|
|
|
```typescript
|
|
// hooks/useTasks.ts
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { api } from '@/api/client'
|
|
import { Task, CreateTaskInput, UpdateTaskInput } from '@/types/task'
|
|
import toast from 'react-hot-toast'
|
|
|
|
export function useTasks(filters?: { projectId?: string; state?: string }) {
|
|
return useQuery({
|
|
queryKey: ['tasks', filters],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<{ tasks: Task[] }>('/tasks', { params: filters })
|
|
return data.tasks
|
|
},
|
|
})
|
|
}
|
|
|
|
export function useTask(taskId: string) {
|
|
return useQuery({
|
|
queryKey: ['tasks', taskId],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<{ task: Task }>(`/tasks/${taskId}`)
|
|
return data.task
|
|
},
|
|
enabled: !!taskId,
|
|
})
|
|
}
|
|
|
|
export function useCreateTask() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async (input: CreateTaskInput) => {
|
|
const { data } = await api.post('/tasks', input)
|
|
return data
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
|
},
|
|
})
|
|
}
|
|
|
|
export function useUpdateTask() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async ({ taskId, updates }: { taskId: string; updates: UpdateTaskInput }) => {
|
|
const { data } = await api.patch(`/tasks/${taskId}`, updates)
|
|
return data
|
|
},
|
|
onSuccess: (_, variables) => {
|
|
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
|
queryClient.invalidateQueries({ queryKey: ['tasks', variables.taskId] })
|
|
},
|
|
})
|
|
}
|
|
|
|
export function useRespondToQuestion() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async ({
|
|
taskId,
|
|
questionId,
|
|
response,
|
|
}: {
|
|
taskId: string
|
|
questionId: string
|
|
response: string
|
|
}) => {
|
|
const { data } = await api.post(`/tasks/${taskId}/respond`, {
|
|
questionId,
|
|
response,
|
|
})
|
|
return data
|
|
},
|
|
onSuccess: (_, variables) => {
|
|
toast.success('Respuesta enviada')
|
|
queryClient.invalidateQueries({ queryKey: ['tasks', variables.taskId] })
|
|
},
|
|
})
|
|
}
|
|
|
|
export function useApproveTask() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async (taskId: string) => {
|
|
const { data } = await api.post(`/tasks/${taskId}/approve`)
|
|
return data
|
|
},
|
|
onSuccess: () => {
|
|
toast.success('Tarea aprobada')
|
|
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
|
},
|
|
})
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// hooks/useProjects.ts
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { api } from '@/api/client'
|
|
import { Project, CreateProjectInput } from '@/types/project'
|
|
|
|
export function useProjects() {
|
|
return useQuery({
|
|
queryKey: ['projects'],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<{ projects: Project[] }>('/projects')
|
|
return data.projects
|
|
},
|
|
})
|
|
}
|
|
|
|
export function useProject(projectId: string) {
|
|
return useQuery({
|
|
queryKey: ['projects', projectId],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<{ project: Project }>(`/projects/${projectId}`)
|
|
return data.project
|
|
},
|
|
enabled: !!projectId,
|
|
})
|
|
}
|
|
|
|
export function useCreateProject() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async (input: CreateProjectInput) => {
|
|
const { data } = await api.post('/projects', input)
|
|
return data
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['projects'] })
|
|
},
|
|
})
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// hooks/useAgents.ts
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { api } from '@/api/client'
|
|
import { Agent } from '@/types/agent'
|
|
|
|
export function useAgents() {
|
|
return useQuery({
|
|
queryKey: ['agents'],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<{ agents: Agent[] }>('/agents')
|
|
return data.agents
|
|
},
|
|
refetchInterval: 5000, // Refetch every 5s
|
|
})
|
|
}
|
|
|
|
export function useAgent(agentId: string) {
|
|
return useQuery({
|
|
queryKey: ['agents', agentId],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<{ agent: Agent }>(`/agents/${agentId}`)
|
|
return data.agent
|
|
},
|
|
enabled: !!agentId,
|
|
refetchInterval: 3000,
|
|
})
|
|
}
|
|
|
|
export function useAgentLogs(agentId: string, limit = 100) {
|
|
return useQuery({
|
|
queryKey: ['agents', agentId, 'logs', limit],
|
|
queryFn: async () => {
|
|
const { data } = await api.get(`/agents/${agentId}/logs`, {
|
|
params: { limit },
|
|
})
|
|
return data.logs
|
|
},
|
|
enabled: !!agentId,
|
|
})
|
|
}
|
|
```
|
|
|
|
## Zustand para Client State
|
|
|
|
```typescript
|
|
// store/authStore.ts
|
|
import { create } from 'zustand'
|
|
import { persist } from 'zustand/middleware'
|
|
|
|
interface User {
|
|
id: string
|
|
email: string
|
|
name: string
|
|
}
|
|
|
|
interface AuthState {
|
|
user: User | null
|
|
token: string | null
|
|
isAuthenticated: boolean
|
|
login: (token: string, user: User) => void
|
|
logout: () => void
|
|
}
|
|
|
|
export const useAuthStore = create<AuthState>()(
|
|
persist(
|
|
(set) => ({
|
|
user: null,
|
|
token: null,
|
|
isAuthenticated: false,
|
|
|
|
login: (token, user) => {
|
|
set({ token, user, isAuthenticated: true })
|
|
},
|
|
|
|
logout: () => {
|
|
set({ user: null, token: null, isAuthenticated: false })
|
|
},
|
|
}),
|
|
{
|
|
name: 'auth-storage',
|
|
}
|
|
)
|
|
)
|
|
```
|
|
|
|
```typescript
|
|
// store/uiStore.ts
|
|
import { create } from 'zustand'
|
|
|
|
interface UIState {
|
|
sidebarOpen: boolean
|
|
activeModal: string | null
|
|
toggleSidebar: () => void
|
|
openModal: (modalId: string) => void
|
|
closeModal: () => void
|
|
}
|
|
|
|
export const useUIStore = create<UIState>((set) => ({
|
|
sidebarOpen: true,
|
|
activeModal: null,
|
|
|
|
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
|
|
|
openModal: (modalId) => set({ activeModal: modalId }),
|
|
|
|
closeModal: () => set({ activeModal: null }),
|
|
}))
|
|
```
|
|
|
|
```typescript
|
|
// store/terminalStore.ts
|
|
import { create } from 'zustand'
|
|
|
|
interface TerminalTab {
|
|
id: string
|
|
agentId: string
|
|
podName: string
|
|
isActive: boolean
|
|
}
|
|
|
|
interface TerminalState {
|
|
tabs: TerminalTab[]
|
|
activeTabId: string | null
|
|
openTerminal: (agentId: string, podName: string) => void
|
|
closeTerminal: (tabId: string) => void
|
|
setActiveTab: (tabId: string) => void
|
|
}
|
|
|
|
export const useTerminalStore = create<TerminalState>((set) => ({
|
|
tabs: [],
|
|
activeTabId: null,
|
|
|
|
openTerminal: (agentId, podName) =>
|
|
set((state) => {
|
|
const existingTab = state.tabs.find((t) => t.agentId === agentId)
|
|
|
|
if (existingTab) {
|
|
return {
|
|
tabs: state.tabs.map((t) => ({
|
|
...t,
|
|
isActive: t.id === existingTab.id,
|
|
})),
|
|
activeTabId: existingTab.id,
|
|
}
|
|
}
|
|
|
|
const newTab: TerminalTab = {
|
|
id: `term-${Date.now()}`,
|
|
agentId,
|
|
podName,
|
|
isActive: true,
|
|
}
|
|
|
|
return {
|
|
tabs: [
|
|
...state.tabs.map((t) => ({ ...t, isActive: false })),
|
|
newTab,
|
|
],
|
|
activeTabId: newTab.id,
|
|
}
|
|
}),
|
|
|
|
closeTerminal: (tabId) =>
|
|
set((state) => {
|
|
const newTabs = state.tabs.filter((t) => t.id !== tabId)
|
|
const newActiveTab = newTabs.length > 0 ? newTabs[0].id : null
|
|
|
|
return {
|
|
tabs: newTabs.map((t, i) => ({
|
|
...t,
|
|
isActive: i === 0,
|
|
})),
|
|
activeTabId: newActiveTab,
|
|
}
|
|
}),
|
|
|
|
setActiveTab: (tabId) =>
|
|
set((state) => ({
|
|
tabs: state.tabs.map((t) => ({
|
|
...t,
|
|
isActive: t.id === tabId,
|
|
})),
|
|
activeTabId: tabId,
|
|
})),
|
|
}))
|
|
```
|
|
|
|
## WebSocket Hook
|
|
|
|
```typescript
|
|
// hooks/useWebSocket.ts
|
|
import { useEffect } from 'use'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import { io, Socket } from 'socket.io-client'
|
|
import { useAuthStore } from '@/store/authStore'
|
|
import toast from 'react-hot-toast'
|
|
|
|
let socket: Socket | null = null
|
|
|
|
export function useWebSocket() {
|
|
const queryClient = useQueryClient()
|
|
const token = useAuthStore((state) => state.token)
|
|
|
|
useEffect(() => {
|
|
if (!token) return
|
|
|
|
// Initialize socket
|
|
socket = io(import.meta.env.VITE_WS_URL || 'http://localhost:3000', {
|
|
auth: { token },
|
|
})
|
|
|
|
socket.on('connect', () => {
|
|
console.log('WebSocket connected')
|
|
})
|
|
|
|
socket.on('disconnect', () => {
|
|
console.log('WebSocket disconnected')
|
|
})
|
|
|
|
// Task events
|
|
socket.on('task:created', (data) => {
|
|
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
|
toast.success(`Nueva tarea: ${data.title}`)
|
|
})
|
|
|
|
socket.on('task:status_changed', (data) => {
|
|
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
|
queryClient.invalidateQueries({ queryKey: ['tasks', data.taskId] })
|
|
|
|
if (data.newState === 'ready_to_test') {
|
|
toast.success('Tarea lista para probar!', {
|
|
duration: 5000,
|
|
})
|
|
}
|
|
})
|
|
|
|
socket.on('task:needs_input', (data) => {
|
|
queryClient.invalidateQueries({ queryKey: ['tasks', data.taskId] })
|
|
toast((t) => (
|
|
<div>
|
|
<p className="font-medium">El agente necesita información</p>
|
|
<p className="text-sm text-gray-600 mt-1">{data.question}</p>
|
|
<button
|
|
onClick={() => {
|
|
// Navigate to task
|
|
window.location.href = `/tasks/${data.taskId}`
|
|
toast.dismiss(t.id)
|
|
}}
|
|
className="mt-2 text-sm text-primary-600 hover:underline"
|
|
>
|
|
Ver tarea →
|
|
</button>
|
|
</div>
|
|
), {
|
|
duration: 10000,
|
|
icon: '❓',
|
|
})
|
|
})
|
|
|
|
socket.on('task:pr_created', (data) => {
|
|
toast.success('Pull Request creado!', {
|
|
action: {
|
|
label: 'Ver PR',
|
|
onClick: () => window.open(data.prUrl, '_blank'),
|
|
},
|
|
})
|
|
})
|
|
|
|
socket.on('task:ready_to_test', (data) => {
|
|
toast.success('Preview deploy completado!', {
|
|
action: {
|
|
label: 'Ver Preview',
|
|
onClick: () => window.open(data.previewUrl, '_blank'),
|
|
},
|
|
})
|
|
})
|
|
|
|
// Agent events
|
|
socket.on('agent:status', (data) => {
|
|
queryClient.invalidateQueries({ queryKey: ['agents'] })
|
|
})
|
|
|
|
// Deploy events
|
|
socket.on('deploy:started', (data) => {
|
|
toast.loading(`Desplegando a ${data.environment}...`, {
|
|
id: `deploy-${data.deploymentId}`,
|
|
})
|
|
})
|
|
|
|
socket.on('deploy:completed', (data) => {
|
|
toast.success(`Deploy completado: ${data.environment}`, {
|
|
id: `deploy-${data.deploymentId}`,
|
|
action: {
|
|
label: 'Abrir',
|
|
onClick: () => window.open(data.url, '_blank'),
|
|
},
|
|
})
|
|
})
|
|
|
|
socket.on('deploy:failed', (data) => {
|
|
toast.error(`Deploy falló: ${data.environment}`, {
|
|
id: `deploy-${data.deploymentId}`,
|
|
})
|
|
})
|
|
|
|
return () => {
|
|
if (socket) {
|
|
socket.disconnect()
|
|
socket = null
|
|
}
|
|
}
|
|
}, [token, queryClient])
|
|
|
|
return socket
|
|
}
|
|
|
|
// Export for manual usage
|
|
export function getSocket() {
|
|
return socket
|
|
}
|
|
```
|
|
|
|
## API Client
|
|
|
|
```typescript
|
|
// api/client.ts
|
|
import axios from 'axios'
|
|
import { useAuthStore } from '@/store/authStore'
|
|
|
|
export const api = axios.create({
|
|
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api',
|
|
timeout: 30000,
|
|
})
|
|
|
|
// Request interceptor
|
|
api.interceptors.request.use((config) => {
|
|
const token = useAuthStore.getState().token
|
|
|
|
if (token) {
|
|
config.headers.Authorization = `Bearer ${token}`
|
|
}
|
|
|
|
return config
|
|
})
|
|
|
|
// Response interceptor
|
|
api.interceptors.response.use(
|
|
(response) => response,
|
|
(error) => {
|
|
if (error.response?.status === 401) {
|
|
useAuthStore.getState().logout()
|
|
window.location.href = '/login'
|
|
}
|
|
|
|
return Promise.reject(error)
|
|
}
|
|
)
|
|
```
|