import { useEffect, useRef, useState } from 'react' import { useParams, useSearchParams, useLocation, useBlocker, useNavigate } from 'react-router-dom' 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, HandoffContextScreen } from '@/components/flowpilot' import { EscalateModal } from '@/components/flowpilot/EscalateModal' import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal' import { HandoffModal } from '@/components/session/HandoffModal' 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() { const { sessionId } = useParams<{ sessionId?: string }>() const [searchParams, setSearchParams] = useSearchParams() const navigate = useNavigate() const location = useLocation() const prefill = (location.state as { prefill?: string } | null)?.prefill || '' const locationState = location.state as { psaTicketId?: string; psaTicket?: PSATicketInfo } | null const psaTicketId = locationState?.psaTicketId const psaTicket = locationState?.psaTicket const isPickup = searchParams.get('pickup') === 'true' const fp = useFlowPilotSession() const branching = useBranching() const prefillHandledRef = useRef(false) const psaTicketHandledRef = useRef(false) const [showOverflow, setShowOverflow] = useState(false) const [showResolve, setShowResolve] = useState(false) const [showEscalate, setShowEscalate] = useState(false) const [showAbandon, setShowAbandon] = useState(false) const [showStatusUpdate, setShowStatusUpdate] = useState(false) const [showHandoff, setShowHandoff] = useState(false) const [resolutionSummary, setResolutionSummary] = useState('') const [submitting, setSubmitting] = useState(false) // Block navigation when session is active const isActiveSession = fp.session?.status === 'active' const blocker = useBlocker( ({ currentLocation, nextLocation }) => !!isActiveSession && currentLocation.pathname !== nextLocation.pathname ) // Auto-submit when navigating from dashboard with prefilled problem useEffect(() => { if (prefill && !prefillHandledRef.current && !sessionId && !fp.session && !fp.isLoading) { prefillHandledRef.current = true fp.startSession({ intake_type: 'free_text', intake_content: { text: prefill } }) } }, [prefill, sessionId, fp.session, fp.isLoading]) // eslint-disable-line react-hooks/exhaustive-deps // Auto-start when navigating from TicketQueue with a PSA ticket useEffect(() => { if (psaTicketId && psaTicket && !psaTicketHandledRef.current && !sessionId && !fp.session && !fp.isLoading) { psaTicketHandledRef.current = true integrationsApi.getConnection().then((conn) => { if (conn?.id) { fp.startSession({ intake_type: 'psa_ticket', intake_content: { ticket_data: { summary: psaTicket.summary, company: psaTicket.company_name, priority: psaTicket.priority_name, }, }, psa_ticket_id: psaTicketId, psa_connection_id: conn.id, }) } }) } }, [psaTicketId, psaTicket, sessionId, fp.session, fp.isLoading]) // eslint-disable-line react-hooks/exhaustive-deps const [pickingUp, setPickingUp] = useState(false) // ── 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(null) const [overlayHandoff, setOverlayHandoff] = useState(null) const [overlayLoading, setOverlayLoading] = useState(false) const [claiming, setClaiming] = useState(false) useEffect(() => { 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, 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(() => { if (fp.session?.is_branching && fp.session.id) { branching.loadBranches(fp.session.id) } }, [fp.session?.is_branching, fp.session?.id]) // eslint-disable-line react-hooks/exhaustive-deps const handlePickupContinue = async () => { if (!sessionId) return setPickingUp(true) try { await aiSessionsApi.pickupSession(sessionId, { resume_mode: 'continue' }) // Clear pickup param and reload the session as active setSearchParams({}) await fp.loadSession(sessionId) } catch (e: unknown) { const message = e instanceof Error ? e.message : 'Failed to pick up session' toast.error(message) } finally { setPickingUp(false) } } const handleBranchSwitch = async (branchId: string) => { if (!fp.session) return const result = await branching.switchBranch(fp.session.id, branchId) if (result) { // Reload session to get updated steps for the switched branch await fp.loadSession(fp.session.id) } } const handlePickupFresh = async (context: string) => { if (!sessionId) return setPickingUp(true) try { await aiSessionsApi.pickupSession(sessionId, { resume_mode: 'fresh', additional_context: context, }) setSearchParams({}) await fp.loadSession(sessionId) } catch (e: unknown) { const message = e instanceof Error ? e.message : 'Failed to pick up session' toast.error(message) } finally { setPickingUp(false) } } // 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 (
) } if (magicState === 'visible' && magicHandoff) { return (
) } // Error state if (fp.error && !fp.session) { return (

{fp.error}

) } // Loading if (fp.isLoading && !fp.session) { return (
) } // Intake screen (no session yet) if (!fp.session) { return (
) } // Escalation pickup briefing if (isPickup && fp.session.status === 'requesting_escalation' && fp.session.escalation_reason) { // Build escalation package from session detail // The escalation_package is in the session but not directly on AISessionDetail — // we use what's available from the session fields const escalationPackage = { problem_summary: fp.session.problem_summary ?? undefined, escalation_reason: fp.session.escalation_reason ?? undefined, // Steps are available from the session detail steps_tried: fp.allSteps.map(step => ({ step_type: step.step_type, description: (step.content as Record)?.text || '', })), } return (
{/* Header */}

Escalation Pickup — {fp.session.problem_summary || 'FlowPilot Session'}

Awaiting pickup
{/* Briefing */}
) } // Active/completed session return (
{/* Navigation guard modal */} {blocker.state === 'blocked' && (

Active Session

You have an active troubleshooting session. If you leave, your session will be paused and you can resume it later.

)} {/* Header with actions */}

{fp.session.problem_summary || 'FlowPilot Session'}

{/* Action buttons — desktop inline, mobile overflow menu */} {fp.session.status === 'active' && ( <> {/* Desktop actions */}
{magicHandoff && ( )} {fp.allSteps.length >= 2 && ( )} {/* Overflow: Pause / Close */}
{showOverflow && ( <>
setShowOverflow(false)} />
)}
{/* Mobile: single overflow menu */}
{showOverflow && ( <>
setShowOverflow(false)} />
{fp.allSteps.length >= 2 && ( )}
)}
)} {/* Status badge — non-active states */} {fp.session.status !== 'active' && ( {fp.session.status} )}
{/* Session content */}
fp.loadSession(fp.session!.id)} onGenerateStatusUpdate={(audience, length, context) => fp.generateStatusUpdate({ audience, length, context })} branches={branching.branches} activeBranchId={branching.activeBranchId} onBranchSwitch={handleBranchSwitch} />
{/* ── Page-level modals (moved from action bar) ── */} {/* Handoff context overlay — re-opened from the toolbar */} {overlayHandoff && (
{ if (e.target === e.currentTarget) setOverlayHandoff(null) }} > {}} onDismiss={() => setOverlayHandoff(null)} dismissible />
)} {/* Resolve modal */} {showResolve && (

Resolve Session

Summarize what fixed the issue. This will be included in the auto-generated documentation.