Files
resolutionflow/frontend/src/pages/FlowPilotSessionPage.tsx
Michael Chihlas e4ef904707 refactor: migrate page components to Design System v4
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 02:04:16 -04:00

245 lines
9.2 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import { useParams, useSearchParams, useLocation, useBlocker, useNavigate } from 'react-router-dom'
import { Sparkles, Loader2, AlertTriangle } from 'lucide-react'
import { useFlowPilotSession } from '@/hooks/useFlowPilotSession'
import { FlowPilotIntake, FlowPilotSession, SessionBriefing } from '@/components/flowpilot'
import { aiSessionsApi } from '@/api'
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 isPickup = searchParams.get('pickup') === 'true'
const fp = useFlowPilotSession()
const prefillHandledRef = useRef(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
const [pickingUp, setPickingUp] = useState(false)
// Load existing session if ID in URL
useEffect(() => {
if (sessionId && !fp.session) {
fp.loadSession(sessionId)
}
}, [sessionId]) // 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 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)
}
}
// Error state
if (fp.error && !fp.session) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<div className="card-flat p-6 text-center max-w-md">
<p className="text-sm text-rose-400">{fp.error}</p>
<button
onClick={() => window.location.reload()}
className="mt-3 text-xs text-[#848b9b] hover:text-[#e2e5eb] transition-colors"
>
Try again
</button>
</div>
</div>
)
}
// Loading
if (fp.isLoading && !fp.session) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<Loader2 size={24} className="animate-spin text-[#848b9b]" />
</div>
)
}
// Intake screen (no session yet)
if (!fp.session) {
return (
<div className="h-full p-6">
<FlowPilotIntake onSubmit={fp.startSession} isLoading={fp.isLoading} defaultProblem={prefill} />
</div>
)
}
// 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<string, string>)?.text || '',
})),
}
return (
<div className="flex h-full flex-col">
{/* Header */}
<div
className="flex items-center gap-3 border-b px-5 py-3 shrink-0"
style={{ borderColor: 'var(--glass-border)' }}
>
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-amber-500/10">
<Sparkles size={14} className="text-amber-400" />
</span>
<div className="flex-1 min-w-0">
<h1 className="font-heading text-sm font-semibold text-[#e2e5eb] truncate">
Escalation Pickup {fp.session.problem_summary || 'FlowPilot Session'}
</h1>
</div>
<span className="font-sans text-xs rounded-md bg-amber-500/10 px-2 py-0.5 text-[0.625rem] uppercase tracking-wider text-amber-400 border border-amber-500/20">
Awaiting pickup
</span>
</div>
{/* Briefing */}
<div className="flex-1 overflow-y-auto p-6">
<div className="mx-auto max-w-2xl">
<SessionBriefing
escalationPackage={escalationPackage}
onContinue={handlePickupContinue}
onFresh={handlePickupFresh}
isProcessing={pickingUp}
/>
</div>
</div>
</div>
)
}
// Active/completed session
return (
<div className="flex h-full flex-col">
{/* Navigation guard modal */}
{blocker.state === 'blocked' && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
<div className="bg-[#14161d] border border-[#1e2130] rounded-xl w-full max-w-md p-6 shadow-lg">
<div className="flex items-center gap-3 mb-3">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-amber-500/10">
<AlertTriangle size={18} className="text-amber-400" />
</span>
<h2 className="text-lg font-heading font-semibold text-[#e2e5eb]">Active Session</h2>
</div>
<p className="mb-4 text-sm text-[#848b9b]">
You have an active troubleshooting session. If you leave, your session will be paused and you can resume it later.
</p>
<div className="flex gap-2">
<button
onClick={() => blocker.reset()}
className="flex-1 rounded-lg bg-gradient-to-r from-cyan-500 to-cyan-400 px-4 py-2.5 text-sm font-semibold text-white hover:brightness-110 active:scale-[0.98] transition-all"
>
Stay in Session
</button>
<button
onClick={() => {
fp.pauseSession()
blocker.proceed()
}}
className="flex-1 rounded-lg bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2.5 text-sm font-medium text-[#e2e5eb] hover:border-[rgba(255,255,255,0.12)] transition-all"
>
Pause & Leave
</button>
</div>
</div>
</div>
)}
{/* Header */}
<div
className="flex items-center gap-3 border-b px-5 py-3 shrink-0"
style={{ borderColor: 'var(--glass-border)' }}
>
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-[rgba(34,211,238,0.10)]">
<Sparkles size={14} className="text-[#22d3ee]" />
</span>
<div className="flex-1 min-w-0">
<h1 className="font-heading text-sm font-semibold text-[#e2e5eb] truncate">
{fp.session.problem_summary || 'FlowPilot Session'}
</h1>
</div>
<span className="font-sans text-xs rounded-md bg-[#14161d] px-2 py-0.5 text-[0.625rem] uppercase tracking-wider text-[#848b9b] border border-[#1e2130]">
{fp.session.status}
</span>
</div>
{/* Session content */}
<div className="flex-1 min-h-0 flex flex-col">
<FlowPilotSession
session={fp.session}
allSteps={fp.allSteps}
currentStep={fp.currentStep}
isProcessing={fp.isProcessing}
canResolve={fp.canResolve}
canEscalate={fp.canEscalate}
documentation={fp.documentation}
psaPushStatus={fp.psaPushStatus}
psaPushError={fp.psaPushError}
memberMappingWarning={fp.memberMappingWarning}
onRespond={fp.respondToStep}
onResolve={fp.resolveSession}
onEscalate={fp.escalateSession}
onPause={fp.pauseSession}
onResume={fp.resumeOwnSession}
onAbandon={async () => {
await fp.abandonSession()
navigate('/sessions')
}}
onRate={fp.rateSession}
onReloadSession={() => fp.loadSession(fp.session!.id)}
/>
</div>
</div>
)
}