import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { AlertTriangle, Clock, Hash, Ticket, Loader2, RefreshCw } from 'lucide-react' import { aiSessionsApi } from '@/api' import type { AISessionSummary } from '@/types/ai-session' import { timeAgo } from '@/lib/timeAgo' import { cn } from '@/lib/utils' interface EscalationQueueProps { onPickup?: (sessionId: string) => void onCountChange?: (count: number) => void } // Static list sort: oldest-first. Longest waiting = most urgent. const sortOldestFirst = (a: AISessionSummary, b: AISessionSummary) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime() // Live-arrival bucket sort: newest-first so the most recent escalation is at // the very top of the list. const sortNewestFirst = (a: AISessionSummary, b: AISessionSummary) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() // How long a freshly-arrived card keeps the slide-in animation class. The // keyframe itself runs 200ms; this just keeps the class on the DOM long // enough for the animation to finish before React removes it on the next // state transition. const NEW_CARD_HIGHLIGHT_MS = 800 // localStorage key for the per-user "seen" set. Tracks session IDs the user // has acknowledged so the unread dot doesn't reappear on refresh. Bounded to // the last `SEEN_CAP` entries to avoid unbounded growth on long-lived // accounts. const SEEN_STORAGE_KEY = 'rf-escalation-seen' const SEEN_CAP = 200 function loadSeenIds(): Set { try { const raw = localStorage.getItem(SEEN_STORAGE_KEY) if (!raw) return new Set() const parsed = JSON.parse(raw) as unknown if (!Array.isArray(parsed)) return new Set() return new Set(parsed.filter((v): v is string => typeof v === 'string')) } catch { return new Set() } } function saveSeenIds(ids: Set): void { try { const arr = Array.from(ids).slice(-SEEN_CAP) localStorage.setItem(SEEN_STORAGE_KEY, JSON.stringify(arr)) } catch { // localStorage unavailable / quota — silent. The dot just won't persist. } } function waitTimeColor(createdAt: string): string { const hours = (Date.now() - new Date(createdAt).getTime()) / 3_600_000 if (hours >= 4) return '#f87171' // danger if (hours >= 1) return '#fbbf24' // warning/amber return '#848b9b' // muted } export function EscalationQueue({ onPickup, onCountChange }: EscalationQueueProps) { const navigate = useNavigate() const [sessions, setSessions] = useState([]) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) // Session IDs that arrived via SSE and should still play the slide-in. const [newIds, setNewIds] = useState>(new Set()) // Track count of unseen arrivals while the tab is backgrounded. const [unseenCount, setUnseenCount] = useState(0) // Per-user seen set persisted in localStorage. Cleared on open, claim, or // explicit dismiss (NOT on hover — Codex correction). The unread dot is // shown for any session id NOT in this set. const [seenIds, setSeenIds] = useState>(() => loadSeenIds()) const markSeen = useCallback((sessionId: string) => { setSeenIds(prev => { if (prev.has(sessionId)) return prev const next = new Set(prev) next.add(sessionId) saveSeenIds(next) return next }) }, []) // Ref mirrors the latest sessions so the SSE handler can diff without // re-binding on every state change. const sessionsRef = useRef([]) useEffect(() => { sessionsRef.current = sessions }, [sessions]) const prefersReducedMotion = useMemo(() => { if (typeof window === 'undefined' || !window.matchMedia) return false return window.matchMedia('(prefers-reduced-motion: reduce)').matches }, []) // ── Tab title flash ── // Capture the original title once at mount. While unseen > 0, prefix it. const originalTitleRef = useRef('') useEffect(() => { originalTitleRef.current = document.title return () => { // Restore on unmount so a leftover "(N) ..." prefix doesn't bleed // into the next page. document.title = originalTitleRef.current } }, []) useEffect(() => { const base = originalTitleRef.current || document.title document.title = unseenCount > 0 ? `(${unseenCount}) ${base}` : base }, [unseenCount]) useEffect(() => { const clearUnseen = () => { if (!document.hidden) setUnseenCount(0) } const onFocus = () => setUnseenCount(0) document.addEventListener('visibilitychange', clearUnseen) window.addEventListener('focus', onFocus) return () => { document.removeEventListener('visibilitychange', clearUnseen) window.removeEventListener('focus', onFocus) } }, []) const loadQueue = useCallback(async () => { setIsLoading(true) setError(null) try { const data = await aiSessionsApi.getEscalationQueue() const sorted = [...data].sort(sortOldestFirst) setSessions(sorted) setNewIds(new Set()) onCountChange?.(sorted.length) } catch { setError('Failed to load escalation queue') } finally { setIsLoading(false) } }, [onCountChange]) useEffect(() => { loadQueue() }, [loadQueue]) // ── SSE subscription ── // Refetch the queue on each `handoff_created` event (the event payload is // intentionally thin — it's a trigger, not the full card data). Diff // against the previous list to identify newly-arrived sessions; prepend // them at the top with the slide-in animation, then keep the rest of the // queue in oldest-first order below. const handleHandoffCreated = useCallback(async () => { let fresh: AISessionSummary[] try { fresh = await aiSessionsApi.getEscalationQueue() } catch { return } const prevIds = new Set(sessionsRef.current.map((s) => s.id)) const arrived = fresh.filter((s) => !prevIds.has(s.id)).sort(sortNewestFirst) const established = fresh.filter((s) => prevIds.has(s.id)).sort(sortOldestFirst) const next = [...arrived, ...established] setSessions(next) onCountChange?.(next.length) if (arrived.length === 0) return const arrivedIds = arrived.map((s) => s.id) setNewIds((prev) => { const merged = new Set(prev) arrivedIds.forEach((id) => merged.add(id)) return merged }) if (document.hidden) { setUnseenCount((c) => c + arrived.length) } window.setTimeout(() => { setNewIds((prev) => { const remaining = new Set(prev) arrivedIds.forEach((id) => remaining.delete(id)) return remaining }) }, NEW_CARD_HIGHLIGHT_MS) }, [onCountChange]) useEffect(() => { const abort = new AbortController() let reconnectTimer: number | null = null let attempt = 0 let cancelled = false const connect = async () => { if (cancelled) return try { await aiSessionsApi.streamEscalations( { onReady: () => { attempt = 0 }, onHandoffCreated: () => { void handleHandoffCreated() }, }, abort.signal, ) // Stream ended cleanly (server hung up). Reconnect quickly. if (!cancelled) { reconnectTimer = window.setTimeout(connect, 1000) } } catch (err) { if (cancelled || abort.signal.aborted) return if (err instanceof DOMException && err.name === 'AbortError') return // Exponential backoff: 1s, 2s, 4s, 8s, 16s, capped at 30s. const delay = Math.min(30_000, 1000 * 2 ** attempt) attempt += 1 reconnectTimer = window.setTimeout(connect, delay) } } void connect() return () => { cancelled = true abort.abort() if (reconnectTimer !== null) window.clearTimeout(reconnectTimer) } }, [handleHandoffCreated]) const handlePickup = (sessionId: string) => { markSeen(sessionId) if (onPickup) { onPickup(sessionId) } else { navigate(`/pilot/${sessionId}?pickup=true`) } } // Click on the card body (anywhere outside Pick Up) marks the session as // seen — the "open" affordance from the unread-dot spec. Pick Up handles // its own marking via handlePickup. Hover deliberately does NOT clear // (Codex correction). const handleCardOpen = (sessionId: string) => { markSeen(sessionId) } if (isLoading) { return (
) } if (error) { return (

{error}

) } if (sessions.length === 0) { return (

No sessions awaiting escalation

) } return (

Awaiting pickup ({sessions.length})

{sessions.map((session) => { const isNew = newIds.has(session.id) const isUnread = !seenIds.has(session.id) return (
handleCardOpen(session.id)} className={cn( 'relative card-flat p-3 sm:p-4 space-y-3 cursor-pointer', isNew && !prefersReducedMotion && 'animate-slide-in-bottom', isNew && prefersReducedMotion && 'animate-fade-in', )} > {/* Unread indicator: 6px dot, top-right corner. Cleared on open (card click) or claim (Pick Up). Persists across refresh via localStorage. */} {isUnread && ( )}

{session.problem_summary || 'Untitled session'}

{session.escalation_reason && (

Reason: {session.escalation_reason}

)}
{session.problem_domain && ( {session.problem_domain} )} {session.step_count} steps {timeAgo(session.created_at)} {session.psa_ticket_id && ( #{session.psa_ticket_id} )}
) })}
) }