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:
20
frontend/src/pages/EscalationQueuePage.tsx
Normal file
20
frontend/src/pages/EscalationQueuePage.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { EscalationQueue } from '@/components/flowpilot'
|
||||
|
||||
export default function EscalationQueuePage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500/10">
|
||||
<AlertTriangle size={16} className="text-amber-400" />
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="font-heading text-xl font-bold text-foreground">Escalation Queue</h1>
|
||||
<p className="text-sm text-muted-foreground">Sessions from your team waiting for pickup</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EscalationQueue />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -41,7 +41,7 @@ const emptyForm: ConnectionForm = {
|
||||
private_key: '',
|
||||
}
|
||||
|
||||
type Tab = 'connection' | 'member-mapping' | 'post-history'
|
||||
type Tab = 'connection' | 'member-mapping' | 'post-history' | 'flowpilot-settings'
|
||||
|
||||
export function IntegrationsPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('connection')
|
||||
@@ -236,6 +236,7 @@ export function IntegrationsPage() {
|
||||
{ id: 'connection' as Tab, label: 'Connection', icon: Plug },
|
||||
{ id: 'member-mapping' as Tab, label: 'Member Mapping', icon: Users },
|
||||
{ id: 'post-history' as Tab, label: 'Post History', icon: History },
|
||||
{ id: 'flowpilot-settings' as Tab, label: 'FlowPilot', icon: Zap },
|
||||
]).map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
@@ -549,6 +550,11 @@ export function IntegrationsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FlowPilot Settings Tab */}
|
||||
{activeTab === 'flowpilot-settings' && (
|
||||
<FlowPilotSettingsTab connection={connection} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
@@ -812,4 +818,221 @@ function MemberMappingTab({ connection }: { connection: PsaConnectionResponse |
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── FlowPilot Settings Tab ─── */
|
||||
|
||||
function FlowPilotSettingsTab({ connection }: { connection: PsaConnectionResponse | null }) {
|
||||
const [settings, setSettings] = useState<Record<string, unknown> | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!connection) {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
integrationsApi.getFlowpilotSettings(connection.id)
|
||||
.then(s => setSettings(s as unknown as Record<string, unknown>))
|
||||
.catch(() => toast.error('Failed to load FlowPilot settings'))
|
||||
.finally(() => setIsLoading(false))
|
||||
}, [connection])
|
||||
|
||||
const updateSetting = async (key: string, value: unknown) => {
|
||||
if (!connection || !settings) return
|
||||
const updated = { ...settings, [key]: value }
|
||||
setSettings(updated)
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await integrationsApi.updateFlowpilotSettings(connection.id, { [key]: value })
|
||||
} catch {
|
||||
toast.error('Failed to save setting')
|
||||
// Revert
|
||||
setSettings(settings)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!connection) {
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<div className="glass-card-static p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">Connect your PSA first to configure FlowPilot settings.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!settings) return null
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-4">
|
||||
<div className="glass-card-static p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Zap className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold text-foreground">FlowPilot Settings</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
Configure how FlowPilot integrates with your ConnectWise PSA when sessions are resolved or escalated.
|
||||
</p>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* Auto-push documentation */}
|
||||
<SettingToggle
|
||||
label="Auto-push documentation"
|
||||
description="Automatically push session documentation to linked tickets on resolution"
|
||||
checked={settings.auto_push as boolean}
|
||||
onChange={(v) => updateSetting('auto_push', v)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
|
||||
{/* Auto-create time entry */}
|
||||
<SettingToggle
|
||||
label="Auto-create time entry"
|
||||
description="Automatically create a time entry when resolving (requires CW member mapping)"
|
||||
checked={settings.auto_time_entry as boolean}
|
||||
onChange={(v) => updateSetting('auto_time_entry', v)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
|
||||
{/* Time rounding */}
|
||||
<SettingSelect
|
||||
label="Time rounding"
|
||||
description="How to round session duration for time entries"
|
||||
value={settings.time_rounding as string}
|
||||
options={[
|
||||
{ value: '15min', label: 'Nearest 15 minutes' },
|
||||
{ value: '30min', label: 'Nearest 30 minutes' },
|
||||
{ value: 'exact', label: 'Exact time' },
|
||||
{ value: 'none', label: "Don't create time entries" },
|
||||
]}
|
||||
onChange={(v) => updateSetting('time_rounding', v)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
|
||||
{/* Note visibility */}
|
||||
<SettingSelect
|
||||
label="Default note visibility"
|
||||
description="Who can see the session notes posted to tickets"
|
||||
value={settings.note_visibility as string}
|
||||
options={[
|
||||
{ value: 'internal', label: 'Internal only' },
|
||||
{ value: 'both', label: 'Internal and external' },
|
||||
]}
|
||||
onChange={(v) => updateSetting('note_visibility', v)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
|
||||
{/* Include diagnostic steps */}
|
||||
<SettingToggle
|
||||
label="Include diagnostic steps in notes"
|
||||
description="When off, only push the summary — not the full diagnostic trail"
|
||||
checked={settings.include_diagnostic_steps as boolean}
|
||||
onChange={(v) => updateSetting('include_diagnostic_steps', v)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
|
||||
{/* Prompt for status on resolution */}
|
||||
<SettingToggle
|
||||
label="Prompt for ticket status on resolution"
|
||||
description="Show a status dropdown when resolving — options pulled from the ticket's board"
|
||||
checked={settings.prompt_status_on_resolution as boolean}
|
||||
onChange={(v) => updateSetting('prompt_status_on_resolution', v)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
|
||||
{/* Prompt for status on escalation */}
|
||||
<SettingToggle
|
||||
label="Prompt for ticket status on escalation"
|
||||
description="Show a status dropdown when escalating — options pulled from the ticket's board"
|
||||
checked={settings.prompt_status_on_escalation as boolean}
|
||||
onChange={(v) => updateSetting('prompt_status_on_escalation', v)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Setting Components ─── */
|
||||
|
||||
function SettingToggle({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onChange: (value: boolean) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{label}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onChange(!checked)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none disabled:opacity-50',
|
||||
checked ? 'bg-primary' : 'bg-[rgba(255,255,255,0.1)]'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform',
|
||||
checked ? 'translate-x-4' : 'translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingSelect({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
label: string
|
||||
description: string
|
||||
value: string
|
||||
options: { value: string; label: string }[]
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{label}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 mb-2">{description}</p>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="w-full max-w-xs rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none disabled:opacity-50"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default IntegrationsPage
|
||||
|
||||
Reference in New Issue
Block a user