Files
aiworker/docs/03-frontend/estado.md
Hector Ros db71705842 Complete documentation for future sessions
- 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>
2026-01-20 00:37:19 +01:00

12 KiB

Gestión de Estado

React Query para Server State

// 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'] })
    },
  })
}
// 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'] })
    },
  })
}
// 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

// 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',
    }
  )
)
// 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 }),
}))
// 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

// 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

// 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)
  }
)