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) yMockCopilotSocket(fixtures para demos/dev). Se elige porVITE_USE_MOCK.useAgentSocket(socket)(src/hooks/): hook puro conuseReducersobre 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:
| Slice | Alimenta | Por qué está separado |
|---|---|---|
activeVoiceConversations | /monitor | Una 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 | /whatsapp | Un 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_indexpara 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 anull:nulles el hecho de que ese turno no consultó el manual.
suggestion_id tiene que ser parseableEl 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.ts → apiFetch(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.
apiFetch / fetchAuthedCualquier 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ódulo | Qué es |
|---|---|
components/copilot/shared/segmented-control.tsx | Toggle group (Día/Semana/Mes, filtros) — accesible (role=group, aria-pressed) |
components/copilot/shared/period-selector.tsx | Dropdown de período |
components/copilot/shared/kpi-card.tsx | Tarjeta de KPI (label + número + delta) sobre ui/Card |
components/copilot/transcript-view.tsx | Transcript + 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.tsx | Score 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.tsx | Modal de detalle de llamada (abre desde el ojo en /logs); envuelve transcript-view |
lib/period.ts | getPeriodOptions, isoDate, daysInRange, pct, deltaLabel, tipos |
lib/chart-colors.ts | Paleta 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
- Crear el componente en
components/copilot/. - Cliente en
src/api/<recurso>.tsusandoapiFetch. - Data con
useQuery. - Ruta en
copilot-routes.tsx(envuelta en<RequireRole>si aplica). - Item en
app-nav.tsx(conroles?si es de supervisor). - Reusar
SegmentedControl/PeriodSelector/KpiCard/ui/*.