feat(escalations): Escalation Mode wedge — live arrival + magic-moment pickup #155

Merged
chihlasm merged 34 commits from feat/escalation-metric-endpoint into main 2026-04-30 21:32:16 +00:00
Showing only changes of commit e910bcc67d - Show all commits

View File

@@ -1,5 +1,8 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { handoffsApi } from '@/api/handoffs'
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'
import { cn } from '@/lib/utils'
import { uploadsApi } from '@/api/uploads'
@@ -63,6 +66,21 @@ export default function AssistantChatPage() {
const location = useLocation()
const navigate = useNavigate()
const { sessionId: urlSessionId } = useParams<{ sessionId?: string }>()
const [searchParams, setSearchParams] = useSearchParams()
const isPickup = searchParams.get('pickup') === 'true'
// Magic-moment handoff-context screen — shown BEFORE the regular chat view
// when a senior tech picks up an escalated session via /pilot/:id?pickup=true.
// Pre-claim, the senior isn't yet escalated_to_id, so we route around the
// regular selectChat path until claim succeeds. "Start here" calls the
// /handoffs/{id}/claim endpoint which flips status to active and sets
// escalated_to_id; then we drop ?pickup=true and let selectChat run.
const [magicState, setMagicState] = useState<'inactive' | 'loading' | 'visible' | 'dismissed'>(
isPickup ? 'loading' : 'inactive',
)
const [magicHandoff, setMagicHandoff] = useState<HandoffResponse | null>(null)
const [overlayHandoff, setOverlayHandoff] = useState<HandoffResponse | null>(null)
const [overlayLoading, setOverlayLoading] = useState(false)
const [claiming, setClaiming] = useState(false)
const [chats, setChats] = useState<ChatListItem[]>([])
const [activeChatId, setActiveChatId] = useState<string | null>(() => {
if (urlSessionId) return urlSessionId
@@ -210,12 +228,89 @@ export default function AssistantChatPage() {
loadChats()
}, [])
// If URL has a session ID, load it
// If URL has a session ID, load it. While the magic-moment handoff-context
// screen is loading or visible, skip selectChat — the senior doesn't yet
// own the session and the regular chat surface would race against the
// claim flow. Once magicState is 'dismissed' (post-claim, or no handoff
// found at all), this effect re-fires and selectChat runs.
useEffect(() => {
if (urlSessionId && urlSessionId !== activeChatId) {
selectChat(urlSessionId)
if (!urlSessionId || urlSessionId === activeChatId) return
if (magicState === 'loading' || magicState === 'visible') return
selectChat(urlSessionId)
}, [urlSessionId, magicState]) // eslint-disable-line react-hooks/exhaustive-deps
// Pickup mode entry: fetch the handoff list (account-scoped via RLS, no
// claim required) to find the latest unclaimed escalate handoff. If found,
// render the magic-moment screen. If none found (legacy sessions
// pre-unification, or the handoff was already claimed by another senior),
// dismiss and let the regular chat surface load.
useEffect(() => {
if (!isPickup || !urlSessionId || magicState !== 'loading') return
let cancelled = false
;(async () => {
try {
const handoffs = await handoffsApi.listHandoffs(urlSessionId)
if (cancelled) return
const target = handoffs.find(h => h.intent === 'escalate' && !h.claimed_by)
if (target) {
setMagicHandoff(target)
setMagicState('visible')
} else {
setMagicState('dismissed')
// Strip ?pickup=true so a refresh doesn't re-enter the loading
// state needlessly.
setSearchParams({})
}
} catch {
if (cancelled) return
setMagicState('dismissed')
setSearchParams({})
}
})()
return () => { cancelled = true }
}, [isPickup, urlSessionId, magicState, setSearchParams])
const handleStartHere = useCallback(async () => {
if (!urlSessionId || !magicHandoff) return
setClaiming(true)
try {
await handoffsApi.claimHandoff(urlSessionId, magicHandoff.id)
// Drop ?pickup=true and dismiss the magic-moment. The session-load
// effect above will then fire because magicState !== 'loading'/'visible'
// and selectChat will populate the chat surface — the senior is now
// escalated_to_id, so GET succeeds and the conversation_messages render
// as chat history.
setSearchParams({})
setMagicState('dismissed')
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Failed to pick up session'
toast.error(message)
} finally {
setClaiming(false)
}
}, [urlSessionId]) // eslint-disable-line react-hooks/exhaustive-deps
}, [urlSessionId, magicHandoff, setSearchParams])
const openHandoffContextOverlay = useCallback(async () => {
if (!activeChatId) return
if (magicHandoff) {
setOverlayHandoff(magicHandoff)
return
}
setOverlayLoading(true)
try {
const handoffs = await handoffsApi.listHandoffs(activeChatId)
const target = handoffs.find(h => h.intent === 'escalate')
if (target) {
setOverlayHandoff(target)
} else {
toast.info('No handoff context available for this session.')
}
} catch {
toast.error('Could not load handoff context')
} finally {
setOverlayLoading(false)
}
}, [activeChatId, magicHandoff])
// Restore session from sessionStorage on mount (when URL has no session ID)
useEffect(() => {
@@ -1234,6 +1329,35 @@ export default function AssistantChatPage() {
// Cleanup blob URLs on unmount
useEffect(() => { return () => { pendingUploads.forEach((u) => { if (u.preview) URL.revokeObjectURL(u.preview) }) } }, []) // eslint-disable-line react-hooks/exhaustive-deps
// Magic-moment handoff-context screen — full-page take-over before claim.
// Loading state shows a centered spinner. Visible state shows the screen
// with the handoff payload; "Start here" claims and dismisses, after which
// the regular chat surface renders.
if (magicState === 'loading') {
return (
<>
<PageMeta title="Picking up session…" />
<div className="flex h-[calc(100vh-3.5rem)] items-center justify-center">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
</div>
</>
)
}
if (magicState === 'visible' && magicHandoff) {
return (
<>
<PageMeta title="Escalation handoff" />
<div className="h-[calc(100vh-3.5rem)] overflow-y-auto p-4 sm:p-8">
<HandoffContextScreen
handoff={magicHandoff}
onStartHere={handleStartHere}
isProcessing={claiming}
/>
</div>
</>
)
}
return (
<>
<PageMeta title="AI Assistant" />
@@ -1332,6 +1456,17 @@ export default function AssistantChatPage() {
{/* Desktop actions — shown when session is active and has messages */}
<div className="hidden sm:flex items-center gap-1.5">
{magicHandoff && (
<button
onClick={openHandoffContextOverlay}
disabled={overlayLoading}
title="Show the handoff context the original engineer sent"
className="flex items-center gap-1.5 rounded-lg border border-default px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:border-hover disabled:opacity-40 transition-colors"
>
<Sparkles size={13} />
Context
</button>
)}
{activePsaTicketId && (
<button
onClick={() => { setSpinOffHint(undefined); setShowNewTicket(true) }}
@@ -1963,6 +2098,23 @@ export default function AssistantChatPage() {
}}
/>
)}
{/* Handoff context overlay — re-opened from the toolbar */}
{overlayHandoff && (
<div
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 backdrop-blur-sm p-4 sm:p-8 animate-fade-in"
onClick={(e) => {
if (e.target === e.currentTarget) setOverlayHandoff(null)
}}
>
<HandoffContextScreen
handoff={overlayHandoff}
onStartHere={() => {}}
onDismiss={() => setOverlayHandoff(null)}
dismissible
/>
</div>
)}
</div>
</>
)