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,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]">•</span>
|
||||
<span>{selectedTicket.priority_name}</span>
|
||||
</>
|
||||
)}
|
||||
{selectedTicket.status_name && (
|
||||
<>
|
||||
<span className="text-[#5a6170]">•</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user