fix(dashboard): resolver bugs críticos de tiempo real en HITL — race conditions, In-Band Auth y multi-stream buffer
- AppShell: corregir condición de carrera REST/WS que perdía tokens de agent_stream_chunk
- init_state atómico + eliminación de doble fuente REST/WS para actualización en tiempo real
- conversation_ended e idempotencia de eventos en máquina de estados por conversación
- Seguridad: migrar JWT de query param a In-Band Auth (primer mensaje {action:auth}) con timeout 5s y cierre 1008
- Multi-stream buffer: reemplazar buffer plano por TTL LRU (200 entradas, 60s TTL) para evitar pisado de tokens entre agentes
- agent_stream_completed ya no borra buffer incondicionalmente — delega purge a la política LRU
- Timer: corregir display de 00:00 en estado PENDING con visualización inmediata + cleanup en stop()
- Tests: 8 tests multi-stream, tests In-Band Auth, tests idempotencia y máquina de estados, tests Timer
- Resultado: 86/86 tests pasan | TypeScript 0 errores
This commit is contained in:
+222
-16
@@ -1,11 +1,83 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { useCallback } from 'react';
|
||||
import { MessageSquare, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { useAppStore } from '@/store/useAppStore';
|
||||
import { wsClient } from '@/services/wsClient';
|
||||
import { streamBuffer } from '@/services/streamBuffer';
|
||||
import ConversationCard from '@/components/monitor/ConversationCard';
|
||||
import ChatFeed from '@/components/monitor/ChatFeed';
|
||||
import InternalNoteBanner from '@/components/monitor/InternalNoteBanner';
|
||||
import EmptyState from '@/components/shared/EmptyState';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// ConnectingPlaceholder — shown when init_state hasn't arrived
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ConnectingPlaceholder() {
|
||||
const wsStatus = useAppStore((s) => s.wsStatus);
|
||||
|
||||
const handleRetry = () => {
|
||||
wsClient.disconnect();
|
||||
wsClient.connect();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex flex-col items-center gap-3 text-text-muted">
|
||||
<Loader2 size={32} className="animate-spin text-accent-orange" />
|
||||
<p className="text-[13px] font-medium">Conectando...</p>
|
||||
<p className="text-[11px]">Esperando datos del servidor</p>
|
||||
<span className="text-[10px] text-text-disabled">
|
||||
Estado: {wsStatus}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetry}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 mt-1 text-[12px] font-medium
|
||||
text-accent-blue border border-accent-blue/30 rounded-lg
|
||||
hover:bg-accent-blue/5 transition-colors"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
Reintentar conexión
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// ConversationEndedBanner
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ConversationEndedBanner({ conversationId }: { conversationId: string }) {
|
||||
const conversationEndedBanner = useAppStore((s) => s.conversationEndedBanner);
|
||||
|
||||
if (conversationEndedBanner !== conversationId) return null;
|
||||
|
||||
return (
|
||||
<div className="shrink-0 px-4 py-2 bg-accent-yellow/10 border-b border-accent-yellow/20">
|
||||
<p className="text-[12px] font-medium text-accent-yellow-dark flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent-yellow" />
|
||||
Conversación finalizada
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// LoadingConversationOverlay
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
function LoadingConversationOverlay() {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-2 text-text-muted">
|
||||
<Loader2 size={24} className="animate-spin text-accent-orange" />
|
||||
<p className="text-[13px]">Cargando conversación...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Component
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -15,23 +87,138 @@ export default function MonitorPage() {
|
||||
const conversations = useAppStore((s) => s.conversations);
|
||||
const selectedConversationId = useAppStore((s) => s.selectedConversationId);
|
||||
const fetchConversations = useAppStore((s) => s.fetchConversations);
|
||||
const fetchConversationWithMessages = useAppStore((s) => s.fetchConversationWithMessages);
|
||||
const setSelectedConversationId = useAppStore((s) => s.setSelectedConversationId);
|
||||
const totalConversations = useAppStore((s) => s.totalConversations);
|
||||
const conversationsOffset = useAppStore((s) => s.conversationsOffset);
|
||||
const selectedConversation = useAppStore((s) => s.selectedConversation);
|
||||
const loadingConversation = useAppStore((s) => s.loadingConversation);
|
||||
const setConversationState = useAppStore((s) => s.setConversationState);
|
||||
const initStateReceived = useAppStore((s) => s.initStateReceived);
|
||||
|
||||
// ── Fetch conversations on mount ─────────────────────────
|
||||
useEffect(() => {
|
||||
fetchConversations();
|
||||
}, [fetchConversations]);
|
||||
// ── No fetchConversations on mount — list comes only from WS init_state (Paso 2) ─
|
||||
|
||||
// ── Selected conversation object ─────────────────────────
|
||||
const selectedConversation = useMemo(
|
||||
() =>
|
||||
conversations.find((c) => c.id === selectedConversationId) ?? null,
|
||||
[conversations, selectedConversationId],
|
||||
// ── Conversation click handler (Paso 6) ──────────────────
|
||||
const handleLoadMore = useCallback(() => {
|
||||
fetchConversations(20, conversationsOffset);
|
||||
}, [fetchConversations, conversationsOffset]);
|
||||
|
||||
const handleConversationClick = useCallback(
|
||||
async (id: string) => {
|
||||
// Generate requestId for correlation (Paso 6 — ignore stale responses)
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
// Update state machine: idle → hydrating (Paso 7)
|
||||
setConversationState(id, 'hydrating');
|
||||
|
||||
// Set loading state BEFORE async fetch (no flicker — Paso 6)
|
||||
useAppStore.setState({
|
||||
selectedConversationId: id,
|
||||
selectedConversation: null,
|
||||
loadingConversation: id,
|
||||
currentRequestId: requestId,
|
||||
conversationEndedBanner: null,
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. Cargar mensajes históricos vía REST
|
||||
await fetchConversationWithMessages(id);
|
||||
|
||||
// Paso 6: Correlation check — if requestId changed, ignore stale response
|
||||
const stateAfter = useAppStore.getState();
|
||||
if (stateAfter.currentRequestId !== requestId) {
|
||||
console.debug('[MonitorPage] Stale REST response ignored for', id);
|
||||
return; // User switched conversation, discard
|
||||
}
|
||||
|
||||
// 2. Verificar si hay streams en buffer para esta conversación
|
||||
// getBufferEntry ahora retorna un ARRAY (multi-stream) — iterar sobre todos
|
||||
const bufferedStreams = streamBuffer.getBufferEntry(id);
|
||||
if (bufferedStreams && bufferedStreams.length > 0) {
|
||||
const sel = useAppStore.getState().selectedConversation;
|
||||
if (sel && sel.id === id) {
|
||||
// Mergear cada stream completado en el store
|
||||
let updatedMessages = [...sel.messages];
|
||||
|
||||
for (const stream of bufferedStreams) {
|
||||
// Ordenar tokens por index y construir contenido completo
|
||||
const sorted = [...stream.tokens].sort(
|
||||
(a, b) => a.index - b.index,
|
||||
);
|
||||
const content = sorted.map((t) => t.token).join('');
|
||||
|
||||
// Verificar si ya existe un mensaje con ese messageId en el store
|
||||
const existingIdx = updatedMessages.findIndex(
|
||||
(m) => m.id === stream.messageId,
|
||||
);
|
||||
if (existingIdx >= 0) {
|
||||
const existing = updatedMessages[existingIdx];
|
||||
if (existing.isStreaming || !existing.content) {
|
||||
updatedMessages[existingIdx] = {
|
||||
...existing,
|
||||
content: existing.content || content,
|
||||
isStreaming: false,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Insertar como nuevo mensaje (ya completo)
|
||||
updatedMessages.push({
|
||||
id: stream.messageId,
|
||||
conversationId: id,
|
||||
role: 'agent' as any,
|
||||
content,
|
||||
timestamp: new Date().toISOString(),
|
||||
isStreaming: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useAppStore.setState({
|
||||
selectedConversation: { ...sel, messages: updatedMessages },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Limpiar buffer para esta conversación
|
||||
streamBuffer.clear(id);
|
||||
|
||||
// 4. Update state machine: check if stream is active
|
||||
const currentState = useAppStore.getState().conversationStates[id];
|
||||
if (currentState === 'hydrating') {
|
||||
// No stream started during hydration → idle
|
||||
setConversationState(id, 'idle');
|
||||
}
|
||||
// If stream already started (via agent_stream_started handler), state is already 'streaming'
|
||||
|
||||
} catch (err) {
|
||||
console.error('[MonitorPage] Failed to load conversation:', err);
|
||||
// Check correlation before resetting
|
||||
const stateAfter = useAppStore.getState();
|
||||
if (stateAfter.currentRequestId === requestId) {
|
||||
setConversationState(id, 'idle');
|
||||
}
|
||||
} finally {
|
||||
// Clear loading state if still current
|
||||
const stateAfter = useAppStore.getState();
|
||||
if (stateAfter.currentRequestId === requestId) {
|
||||
useAppStore.setState({
|
||||
loadingConversation: null,
|
||||
currentRequestId: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[fetchConversationWithMessages, setConversationState],
|
||||
);
|
||||
|
||||
// ── Conversation click handler ────────────────────────────
|
||||
const handleConversationClick = (id: string) => {
|
||||
useAppStore.setState({ selectedConversationId: id });
|
||||
};
|
||||
// ── Show connecting placeholder if init_state hasn't arrived ──
|
||||
if (!initStateReceived) {
|
||||
return <ConnectingPlaceholder />;
|
||||
}
|
||||
|
||||
// Determine what to render in the right panel
|
||||
const isCurrentlyLoading = loadingConversation !== null;
|
||||
const showConversation = selectedConversation && !isCurrentlyLoading;
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
@@ -68,13 +255,32 @@ export default function MonitorPage() {
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{conversationsOffset < totalConversations && (
|
||||
<div className="px-3 pb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoadMore}
|
||||
className="w-full px-3 py-2 text-[12px] font-medium text-text-secondary
|
||||
border border-border rounded-lg hover:bg-bg-hover
|
||||
transition-colors"
|
||||
>
|
||||
Cargar más ({totalConversations - conversationsOffset} restantes)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ── Right panel (flex-1) ──────────────────────────────── */}
|
||||
<main className="flex-1 flex flex-col bg-bg-base overflow-hidden">
|
||||
{selectedConversation ? (
|
||||
{isCurrentlyLoading ? (
|
||||
<LoadingConversationOverlay />
|
||||
) : showConversation ? (
|
||||
<>
|
||||
{/* Conversation ended banner (Paso 3) */}
|
||||
<ConversationEndedBanner conversationId={selectedConversation.id} />
|
||||
|
||||
{/* Chat header */}
|
||||
<div className="shrink-0 px-4 py-2.5 border-b border-border bg-surface flex items-center gap-2">
|
||||
<span className="text-[13px] font-semibold text-text-primary">
|
||||
|
||||
Reference in New Issue
Block a user