Files
resolutionflow/frontend/src/pages/FlowPilotSessionPage.tsx
chihlasm bbe590bfec feat(ai-session): add Phase 2 PSA integration, escalation handoff, and session management
Phase 2 of the FlowPilot-First Pivot connecting AI sessions to ConnectWise PSA:

Slice 1 — PSA Ticket Intake:
- FlowPilotEngine accepts psa_ticket intake with graceful CW API fallback
- Ticket picker on intake screen (refactored TicketPickerModal for dual-mode)
- Ticket context card in session sidebar

Slice 2 — Auto Documentation Push:
- PSA documentation service with resolution/escalation note formatting
- Time entry creation via new ConnectWise provider method
- Automatic retry scheduler (APScheduler, 5min interval, 3 retries)
- PSA push status indicators in frontend with manual retry button
- Member mapping warning when CW member not mapped

Slice 3 — Session Pause/Resume & Escalation Handoff:
- Pause/resume endpoints for same-engineer session bookmarking
- Escalation flow: requesting_escalation status, self-escalation blocked
- Enhanced escalation package with LLM-generated hypotheses/suggestions
- Pickup endpoint with continue/fresh resume modes and briefing step
- Escalation queue (sidebar nav + dedicated page)
- SessionBriefing component with continue/fresh choice UI
- EscalateModal with PSA-aware button text

Slice 4 — Mid-Session Ticket Linking:
- Link ticket retroactively with context injection into system prompt
- Link Ticket button in session sidebar

Slice 5 — FlowPilot PSA Settings:
- Settings tab on IntegrationsPage with 7 configurable options
- Stored as flowpilot_settings JSONB on PsaConnection

Database: 2 migrations (flowpilot_settings, psa_post_log changes, status constraint)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:30:05 +00:00

187 lines
6.4 KiB
TypeScript

import { useEffect, useState } from 'react'
import { useParams, useSearchParams } from 'react-router-dom'
import { Sparkles, Loader2 } 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 isPickup = searchParams.get('pickup') === 'true'
const fp = useFlowPilotSession()
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="glass-card-static 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-muted-foreground hover:text-foreground 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-muted-foreground" />
</div>
)
}
// Intake screen (no session yet)
if (!fp.session) {
return (
<div className="h-full p-6">
<FlowPilotIntake onSubmit={fp.startSession} isLoading={fp.isLoading} />
</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-foreground truncate">
Escalation Pickup {fp.session.problem_summary || 'FlowPilot Session'}
</h1>
</div>
<span className="font-label 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">
{/* 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-primary/10">
<Sparkles size={14} className="text-primary" />
</span>
<div className="flex-1 min-w-0">
<h1 className="font-heading text-sm font-semibold text-foreground truncate">
{fp.session.problem_summary || 'FlowPilot Session'}
</h1>
</div>
<span className="font-label rounded-md bg-card px-2 py-0.5 text-[0.625rem] uppercase tracking-wider text-muted-foreground border border-border">
{fp.session.status}
</span>
</div>
{/* Session content */}
<div className="flex-1 min-h-0">
<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}
onRate={fp.rateSession}
/>
</div>
</div>
)
}