fix(escalations): wire magic-moment + claim into AssistantChatPage
The /pilot/:id route renders AssistantChatPage, not FlowPilotSessionPage (the latter is dead code with no active route). The earlier magic-moment integration sat in the wrong file, so clicking Pick Up from the dashboard navigated to /pilot/:id?pickup=true and AssistantChatPage just loaded the chat surface with no claim — the senior never saw the magic-moment screen and the handoff stayed unclaimed (status escalated, permanently in the queue). Adds full pickup awareness to AssistantChatPage: - ?pickup=true on entry triggers a handoff fetch via handoffsApi.listHandoffs (account-scoped, no claim required). magicState transitions loading → visible (handoff found) or loading → dismissed (no handoff or fetch failed). The dismiss path also strips ?pickup=true from the URL so a refresh doesn't re-enter loading state. - The existing selectChat-from-URL effect is gated on magicState — it skips while we're loading or showing the magic-moment so the chat surface doesn't race the claim flow. After claim it re-fires and populates messages from conversation_messages because the senior is now escalated_to_id and GET succeeds. - Magic-moment renders as full-page take-over (sidebar hidden) until Start here. handleStartHere calls handoffsApi.claimHandoff, drops ?pickup=true, and dismisses — the regular chat then loads. - Toolbar Context button (visible when magicHandoff is in memory) re-opens the screen as a dismissible overlay. Lazy-fetches the handoff when needed. Verified tsc -b clean and Vite HMR picked the file up without errors. The wire-level integration was already verified in earlier commits: listHandoffs returns the unclaimed handoff for a senior pre-claim, claimHandoff flips status escalated → active and sets escalated_to_id. Note: the prior FlowPilotSessionPage magic-moment integration is now in dead code (file is unreferenced from router). Left in place for this commit; will come out in a follow-up cleanup once we're confident the AssistantChatPage path is solid in production. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,8 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
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 { 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 { cn } from '@/lib/utils'
|
||||||
import { uploadsApi } from '@/api/uploads'
|
import { uploadsApi } from '@/api/uploads'
|
||||||
@@ -63,6 +66,21 @@ export default function AssistantChatPage() {
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { sessionId: urlSessionId } = useParams<{ sessionId?: string }>()
|
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 [chats, setChats] = useState<ChatListItem[]>([])
|
||||||
const [activeChatId, setActiveChatId] = useState<string | null>(() => {
|
const [activeChatId, setActiveChatId] = useState<string | null>(() => {
|
||||||
if (urlSessionId) return urlSessionId
|
if (urlSessionId) return urlSessionId
|
||||||
@@ -210,12 +228,89 @@ export default function AssistantChatPage() {
|
|||||||
loadChats()
|
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(() => {
|
useEffect(() => {
|
||||||
if (urlSessionId && urlSessionId !== activeChatId) {
|
if (!urlSessionId || urlSessionId === activeChatId) return
|
||||||
selectChat(urlSessionId)
|
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)
|
// Restore session from sessionStorage on mount (when URL has no session ID)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1234,6 +1329,35 @@ export default function AssistantChatPage() {
|
|||||||
// Cleanup blob URLs on unmount
|
// Cleanup blob URLs on unmount
|
||||||
useEffect(() => { return () => { pendingUploads.forEach((u) => { if (u.preview) URL.revokeObjectURL(u.preview) }) } }, []) // eslint-disable-line react-hooks/exhaustive-deps
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageMeta title="AI Assistant" />
|
<PageMeta title="AI Assistant" />
|
||||||
@@ -1332,6 +1456,17 @@ export default function AssistantChatPage() {
|
|||||||
|
|
||||||
{/* Desktop actions — shown when session is active and has messages */}
|
{/* Desktop actions — shown when session is active and has messages */}
|
||||||
<div className="hidden sm:flex items-center gap-1.5">
|
<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 && (
|
{activePsaTicketId && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setSpinOffHint(undefined); setShowNewTicket(true) }}
|
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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user