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,5 +1,5 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Network, Clock, Hash } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Network, Clock, Hash, Play, Ticket } from 'lucide-react'
|
||||
import type {
|
||||
AISessionDetail,
|
||||
AISessionStepResponse,
|
||||
@@ -12,6 +12,11 @@ import { ConfidenceIndicator } from './ConfidenceIndicator'
|
||||
import { FlowPilotStepCard } from './FlowPilotStepCard'
|
||||
import { FlowPilotActionBar } from './FlowPilotActionBar'
|
||||
import { SessionDocView } from './SessionDocView'
|
||||
import { SessionTicketCard } from './SessionTicketCard'
|
||||
import { TicketPickerModal } from '@/components/session/TicketPickerModal'
|
||||
import { aiSessionsApi } from '@/api'
|
||||
import { toast } from '@/lib/toast'
|
||||
import type { PSATicketInfo } from '@/types/integrations'
|
||||
|
||||
interface FlowPilotSessionProps {
|
||||
session: AISessionDetail
|
||||
@@ -21,9 +26,14 @@ interface FlowPilotSessionProps {
|
||||
canResolve: boolean
|
||||
canEscalate: boolean
|
||||
documentation: SessionDocumentation | null
|
||||
psaPushStatus?: string | null
|
||||
psaPushError?: string | null
|
||||
memberMappingWarning?: string | null
|
||||
onRespond: (response: StepResponseRequest) => void
|
||||
onResolve: (data: ResolveSessionRequest) => Promise<SessionDocumentation>
|
||||
onEscalate: (data: EscalateSessionRequest) => Promise<SessionDocumentation>
|
||||
onPause?: () => Promise<void>
|
||||
onResume?: () => Promise<void>
|
||||
onRate: (rating: number) => void
|
||||
}
|
||||
|
||||
@@ -35,12 +45,55 @@ export function FlowPilotSession({
|
||||
canResolve,
|
||||
canEscalate,
|
||||
documentation,
|
||||
psaPushStatus,
|
||||
psaPushError,
|
||||
memberMappingWarning,
|
||||
onRespond,
|
||||
onResolve,
|
||||
onEscalate,
|
||||
onPause,
|
||||
onResume,
|
||||
onRate,
|
||||
}: FlowPilotSessionProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [showTicketPicker, setShowTicketPicker] = useState(false)
|
||||
const [linkingTicket, setLinkingTicket] = useState(false)
|
||||
|
||||
const handleLinkTicket = async (ticketId: string, _ticket: PSATicketInfo) => {
|
||||
if (!session.psa_connection_id && !session.ticket_data) {
|
||||
// Need a connection ID — try to get it from the integrations API
|
||||
// For now, we'll need it passed in. This will work when ticket_data has it.
|
||||
toast.error('No PSA connection available')
|
||||
return
|
||||
}
|
||||
setLinkingTicket(true)
|
||||
setShowTicketPicker(false)
|
||||
try {
|
||||
// We need the psa_connection_id. If the session doesn't have one,
|
||||
// fetch it from the integrations API
|
||||
let connectionId = session.psa_connection_id
|
||||
if (!connectionId) {
|
||||
const { integrationsApi } = await import('@/api/integrations')
|
||||
const conn = await integrationsApi.getConnection()
|
||||
if (!conn?.id) {
|
||||
toast.error('No PSA connection configured')
|
||||
return
|
||||
}
|
||||
connectionId = conn.id
|
||||
}
|
||||
await aiSessionsApi.linkTicket(session.id, {
|
||||
psa_ticket_id: ticketId,
|
||||
psa_connection_id: connectionId,
|
||||
})
|
||||
toast.success(`Linked to ticket #${ticketId}`)
|
||||
// Reload session to get updated ticket_data
|
||||
window.location.reload()
|
||||
} catch {
|
||||
toast.error('Failed to link ticket')
|
||||
} finally {
|
||||
setLinkingTicket(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll to latest step
|
||||
useEffect(() => {
|
||||
@@ -60,6 +113,11 @@ export function FlowPilotSession({
|
||||
documentation={documentation}
|
||||
onRate={onRate}
|
||||
currentRating={session.session_rating}
|
||||
psaPushStatus={psaPushStatus}
|
||||
psaPushError={psaPushError}
|
||||
memberMappingWarning={memberMappingWarning}
|
||||
sessionId={session.id}
|
||||
ticketId={session.psa_ticket_id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -91,6 +149,23 @@ export function FlowPilotSession({
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Ticket context */}
|
||||
{session.psa_ticket_id ? (
|
||||
<SessionTicketCard
|
||||
ticketId={session.psa_ticket_id}
|
||||
ticketData={session.ticket_data as Record<string, unknown> | null}
|
||||
/>
|
||||
) : session.status === 'active' ? (
|
||||
<button
|
||||
onClick={() => setShowTicketPicker(true)}
|
||||
disabled={linkingTicket}
|
||||
className="w-full flex items-center gap-2 rounded-xl border border-dashed border-border px-3 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:border-primary/30 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Ticket size={14} />
|
||||
{linkingTicket ? 'Linking...' : 'Link Ticket'}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{/* Problem summary */}
|
||||
{session.problem_summary && (
|
||||
<div>
|
||||
@@ -162,10 +237,36 @@ export function FlowPilotSession({
|
||||
canResolve={canResolve}
|
||||
canEscalate={canEscalate}
|
||||
isProcessing={isProcessing}
|
||||
hasPsaTicket={!!session.psa_ticket_id}
|
||||
onResolve={onResolve}
|
||||
onEscalate={onEscalate}
|
||||
onPause={onPause}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Paused banner */}
|
||||
{session.status === 'paused' && onResume && (
|
||||
<div
|
||||
className="flex items-center justify-between border-t px-5 py-3"
|
||||
style={{ borderColor: 'var(--glass-border)', background: 'rgba(16, 17, 20, 0.8)', backdropFilter: 'blur(12px)' }}
|
||||
>
|
||||
<span className="text-sm text-muted-foreground">Session paused</span>
|
||||
<button
|
||||
onClick={onResume}
|
||||
className="flex items-center gap-2 rounded-lg bg-gradient-brand px-4 py-2 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] transition-all"
|
||||
>
|
||||
<Play size={14} />
|
||||
Resume Session
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ticket picker modal for mid-session linking */}
|
||||
<TicketPickerModal
|
||||
open={showTicketPicker}
|
||||
onClose={() => setShowTicketPicker(false)}
|
||||
onSelect={handleLinkTicket}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user