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:
2026-03-19 01:30:05 +00:00
parent 2063a799b0
commit bbe590bfec
37 changed files with 3698 additions and 121 deletions

View File

@@ -1,6 +1,9 @@
import { useState } from 'react'
import { Sparkles, FileText, Terminal } from 'lucide-react'
import { useState, useEffect } from 'react'
import { Sparkles, FileText, Terminal, X, AlertTriangle } from 'lucide-react'
import { TicketPickerModal } from '@/components/session/TicketPickerModal'
import { integrationsApi } from '@/api/integrations'
import type { AISessionCreateRequest } from '@/types/ai-session'
import type { PSATicketInfo, PsaConnectionResponse } from '@/types/integrations'
interface FlowPilotIntakeProps {
onSubmit: (request: AISessionCreateRequest) => void
@@ -11,8 +14,67 @@ export function FlowPilotIntake({ onSubmit, isLoading }: FlowPilotIntakeProps) {
const [text, setText] = useState('')
const [showLogs, setShowLogs] = useState(false)
const [logContent, setLogContent] = useState('')
const [showTicketPicker, setShowTicketPicker] = useState(false)
// PSA connection state
const [psaConnection, setPsaConnection] = useState<PsaConnectionResponse | null>(null)
const [psaChecked, setPsaChecked] = useState(false)
// Selected ticket state
const [selectedTicket, setSelectedTicket] = useState<PSATicketInfo | null>(null)
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null)
const [additionalContext, setAdditionalContext] = useState('')
// Check for PSA connection on mount
useEffect(() => {
let cancelled = false
integrationsApi.getConnection()
.then((conn) => {
if (!cancelled) {
setPsaConnection(conn && conn.is_active ? conn : null)
setPsaChecked(true)
}
})
.catch(() => {
if (!cancelled) setPsaChecked(true)
})
return () => { cancelled = true }
}, [])
const handleTicketSelected = (ticketId: string, ticket: PSATicketInfo) => {
setSelectedTicketId(ticketId)
setSelectedTicket(ticket)
setShowTicketPicker(false)
}
const handleClearTicket = () => {
setSelectedTicketId(null)
setSelectedTicket(null)
setAdditionalContext('')
}
const handleSubmit = () => {
// Ticket-based submission
if (selectedTicket && selectedTicketId && psaConnection) {
const intake_content: Record<string, unknown> = {
text: additionalContext.trim() || undefined,
ticket_data: {
summary: selectedTicket.summary,
company: selectedTicket.company_name,
priority: selectedTicket.priority_name,
},
}
onSubmit({
intake_type: 'psa_ticket',
intake_content,
psa_ticket_id: selectedTicketId,
psa_connection_id: psaConnection.id,
})
return
}
// Free-text / log submission
if (!text.trim() && !logContent.trim()) return
const intake_content: Record<string, unknown> = {}
@@ -26,7 +88,7 @@ export function FlowPilotIntake({ onSubmit, isLoading }: FlowPilotIntakeProps) {
onSubmit({ intake_type, intake_content })
}
const hasContent = text.trim() || logContent.trim()
const hasContent = text.trim() || logContent.trim() || selectedTicket
if (isLoading) {
return (
@@ -42,6 +104,10 @@ export function FlowPilotIntake({ onSubmit, isLoading }: FlowPilotIntakeProps) {
)
}
const submitLabel = selectedTicket && selectedTicketId
? `Start Session with Ticket #${selectedTicketId}`
: 'Start Session'
return (
<div className="flex items-start justify-center pt-[10vh]">
<div className="w-full max-w-2xl">
@@ -50,46 +116,110 @@ export function FlowPilotIntake({ onSubmit, isLoading }: FlowPilotIntakeProps) {
What are you troubleshooting?
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Describe the issue, paste an error message, or paste log output
Describe the issue, paste an error message, or pull context from a ticket
</p>
</div>
<div className="glass-card-static p-5 space-y-4">
{/* Main text area */}
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="e.g. User can't access shared drive after password reset, getting 'Access Denied' in Event Viewer..."
className="w-full rounded-lg border border-border bg-card px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none"
rows={5}
autoFocus
/>
{/* Selected ticket card */}
{selectedTicket && selectedTicketId && (
<div className="rounded-xl border border-primary/20 bg-primary/5 p-4">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-foreground">
<span className="text-primary">#{selectedTicketId}</span>
{' — '}
{selectedTicket.summary}
</p>
<div className="mt-1 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
{selectedTicket.company_name && <span>{selectedTicket.company_name}</span>}
{selectedTicket.priority_name && (
<>
<span className="text-[#5a6170]">&bull;</span>
<span>{selectedTicket.priority_name}</span>
</>
)}
{selectedTicket.status_name && (
<>
<span className="text-[#5a6170]">&bull;</span>
<span>{selectedTicket.status_name}</span>
</>
)}
</div>
</div>
<button
onClick={handleClearTicket}
className="ml-2 rounded-md p-1 text-muted-foreground hover:bg-white/[0.06] hover:text-foreground transition-colors"
>
<X size={14} />
</button>
</div>
{/* Additional context textarea */}
<textarea
value={additionalContext}
onChange={(e) => setAdditionalContext(e.target.value)}
placeholder="Add extra context (optional) — e.g. 'User called back and said it's also affecting their second monitor'"
className="mt-3 w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none"
rows={3}
/>
</div>
)}
{/* Main text area (hidden when ticket is selected) */}
{!selectedTicket && (
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="e.g. User can't access shared drive after password reset, getting 'Access Denied' in Event Viewer..."
className="w-full rounded-lg border border-border bg-card px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none"
rows={5}
autoFocus
/>
)}
{/* Input type toggles */}
<div className="flex items-center gap-2">
<button
onClick={() => setShowLogs(!showLogs)}
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
showLogs
? 'bg-primary/10 text-primary border border-primary/20'
: 'bg-card/50 text-muted-foreground border border-border hover:text-foreground'
}`}
>
<Terminal size={12} />
Paste Logs
</button>
<button
disabled
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium bg-card/50 text-[#5a6170] border border-border opacity-50 cursor-not-allowed"
title="Coming in Phase 2"
>
<FileText size={12} />
Pull from Ticket
</button>
</div>
{!selectedTicket && (
<div className="flex items-center gap-2">
<button
onClick={() => setShowLogs(!showLogs)}
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
showLogs
? 'bg-primary/10 text-primary border border-primary/20'
: 'bg-card/50 text-muted-foreground border border-border hover:text-foreground'
}`}
>
<Terminal size={12} />
Paste Logs
</button>
<button
onClick={() => {
if (psaConnection) {
setShowTicketPicker(true)
}
}}
disabled={!psaChecked || !psaConnection}
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
psaConnection
? 'bg-card/50 text-muted-foreground border border-border hover:text-foreground hover:border-primary/20'
: 'bg-card/50 text-[#5a6170] border border-border opacity-50 cursor-not-allowed'
}`}
title={!psaConnection ? 'Connect your PSA in Settings → Integrations' : 'Search for a ConnectWise ticket'}
>
<FileText size={12} />
Pull from Ticket
</button>
{psaChecked && !psaConnection && (
<span className="flex items-center gap-1 text-[0.625rem] text-amber-400/80">
<AlertTriangle size={10} />
No PSA connected
</span>
)}
</div>
)}
{/* Log paste area */}
{showLogs && (
{!selectedTicket && showLogs && (
<textarea
value={logContent}
onChange={(e) => setLogContent(e.target.value)}
@@ -107,13 +237,20 @@ export function FlowPilotIntake({ onSubmit, isLoading }: FlowPilotIntakeProps) {
<button
onClick={handleSubmit}
disabled={!hasContent}
className="rounded-lg bg-gradient-brand px-5 py-2.5 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] disabled:opacity-40 disabled:shadow-none transition-all"
className="rounded-lg bg-gradient-brand px-5 py-2.5 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] disabled:opacity-40 disabled:shadow-none transition-all whitespace-nowrap"
>
Start Session
{submitLabel}
</button>
</div>
</div>
</div>
{/* Ticket picker modal */}
<TicketPickerModal
open={showTicketPicker}
onClose={() => setShowTicketPicker(false)}
onSelect={handleTicketSelected}
/>
</div>
)
}