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>
384 lines
13 KiB
TypeScript
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>
|
|
)
|
|
}
|