feat(escalations): magic-moment handoff-context screen on pickup
Adds the dedicated 4-section handoff-context view that renders BEFORE
the FlowPilot session for senior techs picking up an escalated
session, then dissolves on "Start here". This is the wedge's
demonstrable magic moment — what the GTM Loom records.
- HandoffContextScreen.tsx: pure presentational, takes a HandoffResponse
plus onStartHere / onDismiss callbacks. Sections: header
(problem summary, domain, step count, escalated-time, priority badge),
"What's been tried" (engineer notes + step-count affordance), "AI
assessment" (likely_cause / suggested_steps / confidence badge), Start
here CTA. Confidence badge accepts both numeric (0..1) and string
("low"/"medium"/"high") shapes — backend currently emits the latter.
Renders an explicit "assessment unavailable" branch when
ai_assessment_data is null (the 5s timeout from 9bdd995 fired).
Honors prefers-reduced-motion (animate-fade-in vs animate-slide-up).
ARIA dialog + focus on the primary CTA. Esc dismisses when used as a
re-openable overlay; pre-claim, Start here is the only exit.
- FlowPilotSessionPage.tsx: on /pilot/:id?pickup=true, fetch the
handoff list via handoffsApi.listHandoffs (account-scoped via RLS,
no claim required) and find the latest unclaimed escalate handoff.
If found, render the magic-moment screen and skip the regular
loadSession (the senior isn't yet escalated_to_id, so GET would
404). Start here calls claimHandoff, drops the pickup query param,
dismisses the screen — the existing loadSession effect then fires
because the senior is now escalated_to_id. A "Context" toolbar
button on active sessions re-opens the screen as a dismissible
overlay (visible only when the senior arrived via the magic-moment
flow this session — handoff lookup on demand).
Verified end-to-end against the running dev stack: listHandoffs
returns the unclaimed handoff with full payload; claim flips session
status from escalated → active; subsequent GET succeeds. tsc -b clean.
Defers (TODO followups): suggested-step chips below the chat input
that prefill on click (requires threading through to
FlowPilotMessageBar); snapshot expansion to include the recent
diagnostic steps pre-claim; toolbar Context button on sessions where
the senior didn't arrive via magic-moment.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import { useParams, useSearchParams, useLocation, useBlocker, useNavigate } from
|
||||
import { Sparkles, Loader2, AlertTriangle, CheckCircle2, ArrowUpRight, FileText, MoreHorizontal, Pause, X } from 'lucide-react'
|
||||
import { useFlowPilotSession } from '@/hooks/useFlowPilotSession'
|
||||
import { useBranching } from '@/hooks/useBranching'
|
||||
import { FlowPilotIntake, FlowPilotSession, SessionBriefing } from '@/components/flowpilot'
|
||||
import { FlowPilotIntake, FlowPilotSession, SessionBriefing, HandoffContextScreen } from '@/components/flowpilot'
|
||||
import { EscalateModal } from '@/components/flowpilot/EscalateModal'
|
||||
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
|
||||
import { HandoffModal } from '@/components/session/HandoffModal'
|
||||
@@ -11,6 +11,7 @@ import { handoffsApi } from '@/api/handoffs'
|
||||
import { aiSessionsApi } from '@/api'
|
||||
import { integrationsApi } from '@/api/integrations'
|
||||
import type { PSATicketInfo } from '@/types/integrations'
|
||||
import type { HandoffResponse } from '@/types/branching'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
export default function FlowPilotSessionPage() {
|
||||
@@ -76,12 +77,95 @@ export default function FlowPilotSessionPage() {
|
||||
|
||||
const [pickingUp, setPickingUp] = useState(false)
|
||||
|
||||
// Load existing session if ID in URL
|
||||
// ── Magic-moment handoff-context screen ──
|
||||
// When the senior arrives via /pilot/:id?pickup=true, the regular session
|
||||
// GET 404s pre-claim (the senior isn't yet escalated_to_id). So we fetch
|
||||
// the handoff list first (account-scoped via RLS, no claim required), find
|
||||
// the most recent unclaimed escalate handoff, and render the magic-moment
|
||||
// screen. "Start here" claims the handoff, then loadSession fires.
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionId && !fp.session) {
|
||||
if (!isPickup || !sessionId || magicState !== 'loading') return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const handoffs = await handoffsApi.listHandoffs(sessionId)
|
||||
if (cancelled) return
|
||||
// Newest unclaimed escalate handoff. listHandoffs orders desc by
|
||||
// created_at on the backend, so .find() picks the latest.
|
||||
const target = handoffs.find((h) => h.intent === 'escalate' && !h.claimed_by)
|
||||
if (target) {
|
||||
setMagicHandoff(target)
|
||||
setMagicState('visible')
|
||||
} else {
|
||||
setMagicState('dismissed')
|
||||
}
|
||||
} catch {
|
||||
if (cancelled) return
|
||||
// Fall through to the legacy SessionBriefing path on failure.
|
||||
setMagicState('dismissed')
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isPickup, sessionId, magicState])
|
||||
|
||||
// Load existing session if ID in URL. Skip while the magic-moment screen is
|
||||
// up — we don't have access to the session detail until claim.
|
||||
useEffect(() => {
|
||||
if (sessionId && !fp.session && magicState !== 'loading' && magicState !== 'visible') {
|
||||
fp.loadSession(sessionId)
|
||||
}
|
||||
}, [sessionId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, magicState]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleStartHere = async () => {
|
||||
if (!sessionId || !magicHandoff) return
|
||||
setClaiming(true)
|
||||
try {
|
||||
await handoffsApi.claimHandoff(sessionId, magicHandoff.id)
|
||||
// Drop the pickup query param and dismiss the screen — the loadSession
|
||||
// effect above will fire because magicState is no longer 'visible'.
|
||||
setSearchParams({})
|
||||
setMagicState('dismissed')
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : 'Failed to pick up session'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setClaiming(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openHandoffContextOverlay = async () => {
|
||||
if (!sessionId) return
|
||||
// Reuse the in-memory copy when we already loaded the handoff during
|
||||
// pickup, otherwise fetch on demand.
|
||||
if (magicHandoff) {
|
||||
setOverlayHandoff(magicHandoff)
|
||||
return
|
||||
}
|
||||
setOverlayLoading(true)
|
||||
try {
|
||||
const handoffs = await handoffsApi.listHandoffs(sessionId)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Load branches when session is branching
|
||||
useEffect(() => {
|
||||
@@ -133,6 +217,28 @@ export default function FlowPilotSessionPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Magic-moment handoff-context screen — shown before the senior tech claims
|
||||
// an escalated session. Takes priority over session loading because the
|
||||
// senior can't load the session detail until claim succeeds.
|
||||
if (magicState === 'loading') {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (magicState === 'visible' && magicHandoff) {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4 sm:p-8">
|
||||
<HandoffContextScreen
|
||||
handoff={magicHandoff}
|
||||
onStartHere={handleStartHere}
|
||||
isProcessing={claiming}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (fp.error && !fp.session) {
|
||||
return (
|
||||
@@ -273,6 +379,17 @@ export default function FlowPilotSessionPage() {
|
||||
<>
|
||||
{/* Desktop actions */}
|
||||
<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-border-default px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:border-border-hover disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<Sparkles size={13} />
|
||||
Context
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowResolve(true)}
|
||||
disabled={!fp.canResolve || fp.isProcessing}
|
||||
@@ -434,6 +551,23 @@ export default function FlowPilotSessionPage() {
|
||||
|
||||
{/* ── Page-level modals (moved from action bar) ── */}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Resolve modal */}
|
||||
{showResolve && (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
|
||||
Reference in New Issue
Block a user