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:
2026-04-28 01:59:28 -04:00
parent 8914391336
commit 0f00ee5e01
8 changed files with 385 additions and 11 deletions

View File

@@ -19,6 +19,7 @@ import type {
ChatMessageRequest,
ChatMessageResponse,
HandoffCreatedEvent,
HandoffAssessmentReadyEvent,
EscalationStreamHandlers,
} from '@/types/ai-session'
@@ -279,6 +280,13 @@ export const aiSessionsApi = {
const parsed = JSON.parse(data) as Record<string, unknown>
if (eventType === 'handoff_created' && parsed.type === 'handoff_created') {
handlers.onHandoffCreated?.(parsed as unknown as HandoffCreatedEvent)
} else if (
eventType === 'handoff_assessment_ready' &&
parsed.type === 'handoff_assessment_ready'
) {
handlers.onAssessmentReady?.(
parsed as unknown as HandoffAssessmentReadyEvent,
)
} else if (eventType === 'ready') {
handlers.onReady?.()
}

View File

@@ -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

View File

@@ -1,6 +1,8 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import axios from 'axios'
import { handoffsApi } from '@/api/handoffs'
import { timeAgo } from '@/lib/timeAgo'
import type { HandoffResponse } from '@/types/branching'
import { HandoffContextScreen } from '@/components/flowpilot/HandoffContextScreen'
import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, MoreHorizontal, Pause, Plus } from 'lucide-react'
@@ -81,6 +83,12 @@ export default function AssistantChatPage() {
const [overlayHandoff, setOverlayHandoff] = useState<HandoffResponse | null>(null)
const [overlayLoading, setOverlayLoading] = useState(false)
const [claiming, setClaiming] = useState(false)
// Codex correction (locked design): once the magic-moment dissolves, the
// AI's `suggested_steps[]` should still be reachable as chips below the
// composer. Click prefills the input; first send hides the strip; explicit
// X also hides. Per-session lifetime — a refresh wipes the state, which is
// fine because the senior can re-open the Context overlay.
const [chipsHidden, setChipsHidden] = useState(false)
const [chats, setChats] = useState<ChatListItem[]>([])
const [activeChatId, setActiveChatId] = useState<string | null>(() => {
if (urlSessionId) return urlSessionId
@@ -299,6 +307,24 @@ export default function AssistantChatPage() {
setSearchParams({})
setMagicState('dismissed')
} catch (e: unknown) {
// Race-condition path (locked design): the loser of the simultaneous
// Pick Up gets a 409 with structured detail so we can name the
// winner and approximate "how long ago." Drop the magic-moment
// (the session is no longer theirs to claim) and let them go back
// to the queue.
if (axios.isAxiosError(e) && e.response?.status === 409) {
const detail = e.response.data?.detail as
| { error?: string; claimed_by_name?: string; claimed_at?: string }
| undefined
if (detail?.error === 'already_claimed') {
const name = detail.claimed_by_name || 'another engineer'
const when = detail.claimed_at ? timeAgo(detail.claimed_at) : 'just now'
toast.info(`Already claimed by ${name} ${when}.`)
setSearchParams({})
setMagicState('dismissed')
return
}
}
const message = e instanceof Error ? e.message : 'Failed to pick up session'
toast.error(message)
} finally {
@@ -328,6 +354,75 @@ export default function AssistantChatPage() {
}
}, [activeChatId, magicHandoff])
// Live-refresh the magic-moment / overlay handoff when the background AI
// enrichment finishes. The backend publishes `handoff_assessment_ready` on
// the escalation bus when `enrich_escalation_async` commits the assessment.
// We subscribe while we have a handoff that is still missing its assessment
// (the placeholder "still generating" state); on a matching event, refetch
// the handoff list and replace state in place. The senior sees the AI
// assessment populate without having to manually reopen the overlay.
//
// Account-scoped at the backend (only handoff.account_id subscribers are
// notified). Single subscription regardless of which view (pre-claim screen
// or post-claim overlay) is showing — both states key off the same handoff.
const trackedHandoffId = magicHandoff?.id ?? overlayHandoff?.id ?? null
const trackedSessionId = magicHandoff?.session_id ?? overlayHandoff?.session_id ?? null
const assessmentMissing =
!!trackedHandoffId &&
!((magicHandoff ?? overlayHandoff)?.ai_assessment) &&
!((magicHandoff ?? overlayHandoff)?.ai_assessment_data)
useEffect(() => {
if (!assessmentMissing || !trackedHandoffId || !trackedSessionId) return
const abort = new AbortController()
let reconnectTimer: number | null = null
let attempt = 0
let cancelled = false
const refetch = async () => {
try {
const handoffs = await handoffsApi.listHandoffs(trackedSessionId)
const fresh = handoffs.find(h => h.id === trackedHandoffId)
if (!fresh || cancelled) return
setMagicHandoff(prev => (prev && prev.id === fresh.id ? fresh : prev))
setOverlayHandoff(prev => (prev && prev.id === fresh.id ? fresh : prev))
} catch {
// best-effort; the user can manually reopen
}
}
const connect = async () => {
if (cancelled) return
try {
await aiSessionsApi.streamEscalations(
{
onReady: () => { attempt = 0 },
onAssessmentReady: (event) => {
if (event.handoff_id !== trackedHandoffId) return
void refetch()
},
},
abort.signal,
)
if (!cancelled) reconnectTimer = window.setTimeout(connect, 1000)
} catch (err) {
if (cancelled || abort.signal.aborted) return
if (err instanceof DOMException && err.name === 'AbortError') return
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)
}
}, [assessmentMissing, trackedHandoffId, trackedSessionId])
// Restore session from sessionStorage on mount (when URL has no session ID)
useEffect(() => {
if (!urlSessionId && activeChatId) {
@@ -1027,6 +1122,7 @@ export default function AssistantChatPage() {
.map((u) => u.preview)
setInput('')
setPendingUploads([])
setChipsHidden(true)
setMessages(prev => [...prev, { role: 'user', content: userMessage, imageUrls: imageUrls.length > 0 ? imageUrls : undefined }])
setLoading(true)
@@ -1721,6 +1817,47 @@ export default function AssistantChatPage() {
/>
)}
{/* Suggested-step chips (Codex correction, locked design):
visible after the magic-moment dissolves (post-claim) so the
senior can pull the AI's suggested next steps into the
composer with one click. Hides on first send or explicit X. */}
{!chipsHidden &&
magicHandoff?.ai_assessment_data?.suggested_steps &&
magicHandoff.ai_assessment_data.suggested_steps.length > 0 &&
magicState === 'dismissed' && (
<div className="px-3 sm:px-6 pt-2 shrink-0">
<div className="max-w-3xl mx-auto flex items-start gap-2">
<p className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground pt-1.5 shrink-0">
Suggested
</p>
<div className="flex flex-wrap gap-1.5 flex-1 min-w-0">
{magicHandoff.ai_assessment_data.suggested_steps.map((step, i) => (
<button
key={i}
type="button"
onClick={() => {
setInput(step)
inputRef.current?.focus()
}}
className="rounded-full border border-default bg-elevated px-3 py-1 text-xs text-foreground hover:bg-accent-dim hover:text-accent-text hover:border-accent/30 transition-colors text-left max-w-full truncate"
title={step}
>
{step}
</button>
))}
</div>
<button
type="button"
onClick={() => setChipsHidden(true)}
aria-label="Hide suggestions"
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-elevated transition-colors shrink-0"
>
<X size={12} />
</button>
</div>
</div>
)}
{/* Rich Input */}
<div className="px-3 sm:px-6 py-3 shrink-0">
<div

View File

@@ -274,7 +274,18 @@ export interface HandoffCreatedEvent {
created_at: string | null
}
// Published by `enrich_escalation_async` after the background AI enrichment
// finishes. Connected magic-moment screens use this to refetch the handoff
// and re-render the AI assessment section in place.
export interface HandoffAssessmentReadyEvent {
type: 'handoff_assessment_ready'
handoff_id: string
session_id: string
has_assessment: boolean
}
export interface EscalationStreamHandlers {
onReady?: () => void
onHandoffCreated?: (event: HandoffCreatedEvent) => void
onAssessmentReady?: (event: HandoffAssessmentReadyEvent) => void
}