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 { 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>
|
||||
</>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user