Skip to main content

Estructura del frontend

Repo aura-front: React 18 + TypeScript + Vite, Tailwind + shadcn/ui (Radix), TanStack Query, lucide-react. Auth con @cuatro-quinas/ajolote-client-web.

Separación socket / hook / UI

El core del dashboard separa la fuente de eventos del consumidor:

  • CopilotSocket (src/api/socket/): interface con dos implementaciones — RealCopilotSocket (WebSocket nativo, reconexión con backoff) y MockCopilotSocket (fixtures para demos/dev). Se elige por VITE_USE_MOCK.
  • useAgentSocket(socket) (src/hooks/): hook puro con useReducer sobre los eventos. Sin datos hardcodeados, testeable con un socket fake.
  • Componentes de presentación (src/components/copilot/): reciben estado y disparan callbacks; no tocan el WebSocket.

Los slices de conversación en vivo

El reducer de useAgentSocket rutea conversation_snapshot por channel, no por source, a dos slices separados:

SliceAlimentaPor qué está separado
activeVoiceConversations/monitorUna llamada pasa, se sigue en vivo y termina en minutos. Comparte slice con el camino Retell porque el panel muestra lo mismo: una llamada ocurriendo ahora
whatsappThreads/whatsappUn hilo es asincrónico: se ordena por último mensaje y no tiene duración. Una tarjeta con duración de llamada no significa nada acá

Dos detalles del merge de transcript (mergeTranscript), que valen para las dos vistas:

  • el backend manda solo los últimos N turnos (40), con first_turn_index para ubicar la ventana, así que las posiciones del array se corren cuando desliza. La alineación con lo que ya está en pantalla se hace por contenido (alignOffset: el solapamiento más largo entre la cola de lo que tenemos y la cabeza de lo que llegó), que cubre los dos casos con la misma cuenta — un hilo que todavía crece (offset 0) y una ventana que ya desliza. Alinear por posición pierde los scores viejos en el primer caso, y alinear "desde el final" no arregla el segundo;
  • el score de RAG viene en el turno, así que se lee de ahí. Un snapshot en vivo solo lo trae para la respuesta que acaba de terminar: en los turnos anteriores los campos vienen ausentes, que significa "no está en este payload", y lo que ya estaba en pantalla se conserva. El catch-up los trae todos desde messages, y por eso los scores sobreviven un refresh. Ausente nunca se colapsa a null: null es el hecho de que ese turno no consultó el manual.
suggestion_id tiene que ser parseable

El feedback de un turno usa ${conversationId}_${turnIndex}, que es lo que el backend puede volver a parsear (/_t?(\d+)$/ en feedback.service.ts). El monitor viejo arma ${callId}_monitor, que no matchea: esas filas nunca pueden recomputar el reclamo del paciente, así que la limpieza de lecciones huérfanas no funciona para ellas. No copiar ese patrón.

turnIndex es el messages.turn_index del turno, no su posición en el chat: se calcula como firstTurnIndex + posición, con first_turn_index que manda el snapshot. Usar la posición haría que el id nombre un turno distinto cada vez que la ventana desliza.

Cliente API

Todo el acceso al backend pasa por src/api/client.tsapiFetch(path, init), que resuelve la base URL y usa fetchAuthed (Bearer + refresh). Los módulos de src/api/* (metrics, audit, feedback, audio-fork, retell, conversations) lo importan; no redefinen el fetch.

Siempre apiFetch / fetchAuthed

Cualquier request autenticado debe ir por apiFetch. Un fetch plano no manda el Bearer, y el backend no puede identificar al usuario (req.user vacío) → los eventos de auditoría salen sin user_id y los endpoints gateados dan 403.

Data fetching (React Query)

Los paneles usan useQuery con key [endpoint, ...filtros]. Esto:

  • evita races: al cambiar filtros rápido, respuestas viejas no pisan a las nuevas (React Query descarta resultados de keys obsoletas);
  • da caché y dedupe gratis.

No usar useEffect + fetch manual para data de servidor.

Componentes y helpers compartidos

Para no duplicar entre paneles:

MóduloQué es
components/copilot/shared/segmented-control.tsxToggle group (Día/Semana/Mes, filtros) — accesible (role=group, aria-pressed)
components/copilot/shared/period-selector.tsxDropdown de período
components/copilot/shared/kpi-card.tsxTarjeta de KPI (label + número + delta) sobre ui/Card
components/copilot/transcript-view.tsxTranscript + marcaciones de una llamada; reusado por /history y el modal de /logs. Prop canEditMarkings habilita editar/eliminar (supervisor) y spacious el layout ampliado del modal
components/copilot/shared/rag-badge.tsxScore de RAG de una respuesta del bot, con el gate de superadmin adentro del componente (un solo lugar donde cambiarlo). No renderiza nada si el turno no registró retrieval — "sin dato" no es "no consultó"
components/copilot/call-detail-modal.tsxModal de detalle de llamada (abre desde el ojo en /logs); envuelve transcript-view
lib/period.tsgetPeriodOptions, isoDate, daysInRange, pct, deltaLabel, tipos
lib/chart-colors.tsPaleta compartida de recharts
components/ui/*Primitivos shadcn (Card, Badge, Button, Select, PanelCard…)

Reusar los primitivos ui/ en vez de <div> con clases repetidas.

Roles en la UI

Ver Autenticación. En resumen: useRole() + RequireRole (rutas) + roles? en NAV_ITEMS (nav). El front solo oculta; la autoridad es el backend.

Agregar una pantalla nueva

  1. Crear el componente en components/copilot/.
  2. Cliente en src/api/<recurso>.ts usando apiFetch.
  3. Data con useQuery.
  4. Ruta en copilot-routes.tsx (envuelta en <RequireRole> si aplica).
  5. Item en app-nav.tsx (con roles? si es de supervisor).
  6. Reusar SegmentedControl / PeriodSelector / KpiCard / ui/*.