fix(escalations): wire magic-moment + claim into AssistantChatPage
All checks were successful
Mirror to GitHub / mirror (push) Successful in 4s
CI / frontend (pull_request) Successful in 5m0s
CI / backend (pull_request) Successful in 10m2s
CI / e2e (pull_request) Successful in 10m39s

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:
2026-04-27 23:23:00 -04:00
parent 5085bb47c2
commit e910bcc67d

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>
</>
)