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>
This commit is contained in:
@@ -1,12 +1,17 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
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 } from '@/components/flowpilot'
|
||||
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(() => {
|
||||
@@ -15,6 +20,40 @@ export default function FlowPilotSessionPage() {
|
||||
}
|
||||
}, [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 (
|
||||
@@ -32,6 +71,15 @@ export default function FlowPilotSessionPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
@@ -41,6 +89,56 @@ export default function FlowPilotSessionPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// 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">
|
||||
@@ -72,9 +170,14 @@ export default function FlowPilotSessionPage() {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user