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>
187 lines
6.4 KiB
TypeScript
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>
|
|
)
|
|
}
|