- 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
205 lines
7.0 KiB
TypeScript
205 lines
7.0 KiB
TypeScript
import {
|
|
useState,
|
|
useRef,
|
|
useCallback,
|
|
useEffect,
|
|
forwardRef,
|
|
useImperativeHandle,
|
|
} from 'react';
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// Imperative handle
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
export interface TimerHandle {
|
|
start: () => void;
|
|
stop: () => void;
|
|
getElapsed: () => number; // returns elapsed seconds
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// Helpers
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
function formatMMSS(totalSeconds: number): string {
|
|
const m = Math.floor(totalSeconds / 60);
|
|
const s = Math.floor(totalSeconds % 60);
|
|
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
|
}
|
|
|
|
function storageKey(caseId: string | number): string {
|
|
return `timer_case_${caseId}`;
|
|
}
|
|
|
|
interface StoredTimer {
|
|
startTimestamp: number; // Date.now() when started
|
|
accumulated: number; // seconds accumulated before last start
|
|
}
|
|
|
|
function readStorage(caseId: string | number): StoredTimer | null {
|
|
try {
|
|
const raw = localStorage.getItem(storageKey(caseId));
|
|
if (!raw) return null;
|
|
return JSON.parse(raw) as StoredTimer;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writeStorage(caseId: string | number, data: StoredTimer): void {
|
|
try {
|
|
localStorage.setItem(storageKey(caseId), JSON.stringify(data));
|
|
} catch {
|
|
// localStorage unavailable
|
|
}
|
|
}
|
|
|
|
export function clearTimerStorage(caseId: string | number): void {
|
|
try {
|
|
localStorage.removeItem(storageKey(caseId));
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// Props
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
interface TimerProps {
|
|
caseId: string | number;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// Component
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
const Timer = forwardRef<TimerHandle, TimerProps>(({ caseId }, ref) => {
|
|
const [displaySeconds, setDisplaySeconds] = useState<number>(0);
|
|
|
|
// Mutable refs to avoid re-renders on tick
|
|
const isRunningRef = useRef(false);
|
|
const accumulatedRef = useRef(0);
|
|
const startTimestampRef = useRef<number | null>(null);
|
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
// ── Restore from localStorage on mount ─────────────────────
|
|
useEffect(() => {
|
|
const stored = readStorage(caseId);
|
|
if (stored) {
|
|
accumulatedRef.current = stored.accumulated;
|
|
|
|
// If the timer was running when the page closed, treat startTimestamp
|
|
// as the new start point but keep the accumulated time.
|
|
if (stored.startTimestamp > 0) {
|
|
const elapsedSinceStore =
|
|
Math.floor((Date.now() - stored.startTimestamp) / 1000);
|
|
const total = stored.accumulated + elapsedSinceStore;
|
|
accumulatedRef.current = total;
|
|
setDisplaySeconds(total);
|
|
// Don't auto-start — the parent must call start() explicitly
|
|
} else {
|
|
setDisplaySeconds(stored.accumulated);
|
|
}
|
|
}
|
|
}, [caseId]);
|
|
|
|
// ── Imperative API ────────────────────────────────────────
|
|
|
|
const start = useCallback(() => {
|
|
if (isRunningRef.current) return; // already running
|
|
|
|
isRunningRef.current = true;
|
|
startTimestampRef.current = Date.now();
|
|
|
|
// Persist: store startTimestamp + accumulated so far
|
|
writeStorage(caseId, {
|
|
startTimestamp: startTimestampRef.current,
|
|
accumulated: accumulatedRef.current,
|
|
});
|
|
|
|
// Mostrar valor actual INMEDIATAMENTE (sin esperar 1s al primer tick)
|
|
setDisplaySeconds(accumulatedRef.current);
|
|
|
|
intervalRef.current = setInterval(() => {
|
|
if (startTimestampRef.current === null) return;
|
|
const elapsed = Math.floor(
|
|
(Date.now() - startTimestampRef.current) / 1000,
|
|
);
|
|
const total = accumulatedRef.current + elapsed;
|
|
setDisplaySeconds(total);
|
|
}, 1000);
|
|
}, [caseId]);
|
|
|
|
const stop = useCallback(() => {
|
|
if (!isRunningRef.current) return;
|
|
|
|
isRunningRef.current = false;
|
|
|
|
if (intervalRef.current) {
|
|
clearInterval(intervalRef.current);
|
|
intervalRef.current = null;
|
|
}
|
|
|
|
// Finalize accumulated time
|
|
if (startTimestampRef.current !== null) {
|
|
const elapsed = Math.floor(
|
|
(Date.now() - startTimestampRef.current) / 1000,
|
|
);
|
|
accumulatedRef.current += elapsed;
|
|
startTimestampRef.current = null;
|
|
}
|
|
|
|
// Persist: accumulated with no running timer
|
|
writeStorage(caseId, {
|
|
startTimestamp: 0,
|
|
accumulated: accumulatedRef.current,
|
|
});
|
|
|
|
setDisplaySeconds(accumulatedRef.current);
|
|
}, [caseId]);
|
|
|
|
const getElapsed = useCallback((): number => {
|
|
if (isRunningRef.current && startTimestampRef.current !== null) {
|
|
const elapsed = Math.floor(
|
|
(Date.now() - startTimestampRef.current) / 1000,
|
|
);
|
|
return accumulatedRef.current + elapsed;
|
|
}
|
|
return accumulatedRef.current;
|
|
}, []);
|
|
|
|
useImperativeHandle(ref, () => ({ start, stop, getElapsed }), [
|
|
start,
|
|
stop,
|
|
getElapsed,
|
|
]);
|
|
|
|
// ── Cleanup on unmount ────────────────────────────────────
|
|
useEffect(() => {
|
|
return () => {
|
|
// 1. Primero: persistir estado vía stop() (ya es idempotente)
|
|
stop();
|
|
// 2. Segundo: anular el intervalo (por si stop no lo hizo)
|
|
if (intervalRef.current) {
|
|
clearInterval(intervalRef.current);
|
|
intervalRef.current = null;
|
|
}
|
|
// 3. Tercero: resetear bandera (para StrictMode)
|
|
isRunningRef.current = false;
|
|
};
|
|
}, [stop]);
|
|
|
|
// ── Render ────────────────────────────────────────────────
|
|
|
|
return (
|
|
<span className="font-mono text-xl font-bold text-text-primary tabular-nums">
|
|
{formatMMSS(displaySeconds)}
|
|
</span>
|
|
);
|
|
});
|
|
|
|
Timer.displayName = 'Timer';
|
|
|
|
export default Timer;
|