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>
This commit is contained in:
@@ -26,6 +26,34 @@ const sortNewestFirst = (a: AISessionSummary, b: AISessionSummary) =>
|
||||
// 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
|
||||
@@ -42,6 +70,20 @@ export function EscalationQueue({ onPickup, onCountChange }: EscalationQueueProp
|
||||
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.
|
||||
@@ -190,6 +232,7 @@ export function EscalationQueue({ onPickup, onCountChange }: EscalationQueueProp
|
||||
}, [handleHandoffCreated])
|
||||
|
||||
const handlePickup = (sessionId: string) => {
|
||||
markSeen(sessionId)
|
||||
if (onPickup) {
|
||||
onPickup(sessionId)
|
||||
} else {
|
||||
@@ -197,6 +240,14 @@ export function EscalationQueue({ onPickup, onCountChange }: EscalationQueueProp
|
||||
}
|
||||
}
|
||||
|
||||
// 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">
|
||||
@@ -256,15 +307,26 @@ export function EscalationQueue({ onPickup, onCountChange }: EscalationQueueProp
|
||||
<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(
|
||||
'card-flat p-3 sm:p-4 space-y-3',
|
||||
'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'}
|
||||
@@ -303,7 +365,10 @@ export function EscalationQueue({ onPickup, onCountChange }: EscalationQueueProp
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => handlePickup(session.id)}
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user