Files
resolutionflow/frontend/src/components/flowpilot/EscalationQueue.tsx
Michael Chihlas 0f00ee5e01 feat(escalations): close out plan-locked wedge polish
Four items from the design-plan audit, all flagged as locked-design or
Codex corrections, shipped together so the GTM demo path covers them
end-to-end before bug bash.

1. Live AI assessment refresh on the magic-moment screen. Backend already
   publishes handoff_assessment_ready when enrich_escalation_async commits;
   wire the frontend listener so the senior sees the assessment populate
   without a manual reopen. New event type + onAssessmentReady handler on
   streamEscalations; AssistantChatPage opens a scoped SSE subscription
   whenever it tracks a handoff missing its assessment, refetches on match,
   and replaces magicHandoff / overlayHandoff in place. Closes the loop on
   the async-assessment commit e8ba74e.

2. Suggested-step chips below the chat input. Locked design from the plan
   (Codex correction). Chip strip renders above the composer post-claim
   when ai_assessment_data.suggested_steps[] is non-empty. Click prefills
   the input and focuses; first send or explicit X hides for the session.

3. Unread 6px dot on EscalationQueue cards. localStorage-persisted seen
   set (rf-escalation-seen, capped 200). Dot top-right when not seen.
   Cleared on open (card click) or claim (Pick Up) — NOT on hover, per
   Codex correction. Pick Up stops propagation so it doesn't double-fire.

4. Race-condition toast on claim conflict. The /claim endpoint previously
   silently overwrote claimed_by — both seniors thought they owned the
   session. New HandoffAlreadyClaimedError carries the winner's id/name/
   timestamp; claim_session rejects different-user re-claims (same-user is
   idempotent for double-click safety); endpoint returns 409 with
   structured detail. AssistantChatPage.handleStartHere extracts and
   surfaces "Already claimed by {name} {time_ago}." via toast, drops
   ?pickup=true, dismisses magic-moment so the loser flows back to queue.

Tests: 2 new unit tests in test_handoff_manager.py (conflict raises,
same-user idempotent). Full handoff + escalation suite (34 tests) green.
Frontend tsc -b clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 01:59:28 -04:00

384 lines
13 KiB
TypeScript

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<string> {
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<string>): 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<AISessionSummary[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Session IDs that arrived via SSE and should still play the slide-in.
const [newIds, setNewIds] = useState<Set<string>>(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<Set<string>>(() => 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<AISessionSummary[]>([])
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<string>('')
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 (
<div className="flex items-center justify-center py-12">
<Loader2 size={20} className="animate-spin text-muted-foreground" />
</div>
)
}
if (error) {
return (
<div className="py-12 text-center">
<p className="text-sm text-danger">{error}</p>
<button
onClick={loadQueue}
className="mt-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Try again
</button>
</div>
)
}
if (sessions.length === 0) {
return (
<div className="py-12 text-center">
<AlertTriangle size={24} className="mx-auto mb-2 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No sessions awaiting escalation</p>
<button
onClick={loadQueue}
className="mt-3 flex items-center gap-1.5 mx-auto text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw size={12} />
Refresh
</button>
</div>
)
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between px-1">
<h3
className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground"
aria-label={`${sessions.length} escalations awaiting pickup`}
>
Awaiting pickup ({sessions.length})
</h3>
<button
onClick={loadQueue}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw size={10} />
Refresh
</button>
</div>
<div role="region" aria-live="polite" className="space-y-3">
{sessions.map((session) => {
const isNew = newIds.has(session.id)
const isUnread = !seenIds.has(session.id)
return (
<div
key={session.id}
onClick={() => 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 && (
<span
aria-label="Unread escalation"
className="absolute top-2 right-2 inline-block w-1.5 h-1.5 rounded-full bg-accent"
/>
)}
<div>
<p className="text-sm font-semibold text-foreground">
{session.problem_summary || 'Untitled session'}
</p>
{session.escalation_reason && (
<p className="mt-1 text-xs text-warning line-clamp-2">
Reason: {session.escalation_reason}
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
{session.problem_domain && (
<span className="font-sans rounded-md bg-accent-dim px-1.5 py-0.5 text-[0.5625rem] uppercase tracking-wider text-accent-text">
{session.problem_domain}
</span>
)}
<span className="flex items-center gap-1">
<Hash size={10} />
{session.step_count} steps
</span>
<span
className="flex items-center gap-1 font-medium"
style={{ color: waitTimeColor(session.created_at) }}
>
<Clock size={10} />
{timeAgo(session.created_at)}
</span>
{session.psa_ticket_id && (
<span className="flex items-center gap-1 text-accent-text">
<Ticket size={10} />
#{session.psa_ticket_id}
</span>
)}
</div>
<div className="flex justify-end">
<button
onClick={(e) => {
e.stopPropagation()
handlePickup(session.id)
}}
className="rounded-lg bg-primary text-white px-4 py-2.5 text-sm font-semibold hover:brightness-110 active:scale-[0.98] transition-all"
>
Pick Up
</button>
</div>
</div>
)
})}
</div>
</div>
)
}