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

@@ -10,6 +10,7 @@ import type {
SessionDocumentation,
AISessionSummary,
AISessionDetail,
PickupSessionRequest,
} from '@/types/ai-session'
export const aiSessionsApi = {
@@ -62,6 +63,42 @@ export const aiSessionsApi = {
async rateSession(sessionId: string, data: { rating: number; feedback?: string }): Promise<void> {
await apiClient.post(`/ai-sessions/${sessionId}/rate`, data)
},
async retryPsaPush(sessionId: string): Promise<{ psa_push_status: string; psa_push_error: string | null }> {
const response = await apiClient.post<{ psa_push_status: string; psa_push_error: string | null }>(
`/ai-sessions/${sessionId}/retry-psa-push`
)
return response.data
},
async pauseSession(sessionId: string): Promise<void> {
await apiClient.post(`/ai-sessions/${sessionId}/pause`)
},
async resumeSession(sessionId: string): Promise<void> {
await apiClient.post(`/ai-sessions/${sessionId}/resume`)
},
async pickupSession(sessionId: string, data: PickupSessionRequest): Promise<StepResponseResponse> {
const response = await apiClient.post<StepResponseResponse>(
`/ai-sessions/${sessionId}/pickup`,
data
)
return response.data
},
async linkTicket(sessionId: string, data: { psa_ticket_id: string; psa_connection_id: string }): Promise<AISessionDetail> {
const response = await apiClient.post<AISessionDetail>(
`/ai-sessions/${sessionId}/link-ticket`,
data
)
return response.data
},
async getEscalationQueue(): Promise<AISessionSummary[]> {
const response = await apiClient.get<AISessionSummary[]>('/ai-sessions/escalation-queue')
return response.data
},
}
export default aiSessionsApi

View File

@@ -1,6 +1,6 @@
import { apiClient } from './client'
import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types'
import type { TicketLinkResponse, PSATicketSearchResult, PSATicketInfo, PSATicketStatusItem, PsaPreviewResponse, PsaPostResponse, PsaPostLogEntry, PsaMemberResponse, PsaMemberMappingResponse, AutoMatchResult } from '@/types/integrations'
import type { TicketLinkResponse, PSATicketSearchResult, PSATicketInfo, PSATicketStatusItem, PsaPreviewResponse, PsaPostResponse, PsaPostLogEntry, PsaMemberResponse, PsaMemberMappingResponse, AutoMatchResult, FlowpilotSettings } from '@/types/integrations'
export const integrationsApi = {
getConnection: () =>
@@ -27,6 +27,10 @@ export const integrationsApi = {
apiClient.post<PsaMemberMappingResponse[]>('/integrations/psa/member-mappings', mappings).then(r => r.data),
autoMatchMembers: () =>
apiClient.post<AutoMatchResult>('/integrations/psa/member-mappings/auto-match').then(r => r.data),
getFlowpilotSettings: (connectionId: string) =>
apiClient.get<FlowpilotSettings>(`/integrations/psa/connections/${connectionId}/flowpilot-settings`).then(r => r.data),
updateFlowpilotSettings: (connectionId: string, data: Partial<FlowpilotSettings>) =>
apiClient.put<FlowpilotSettings>(`/integrations/psa/connections/${connectionId}/flowpilot-settings`, data).then(r => r.data),
}
export const sessionPsaApi = {

View File

@@ -0,0 +1,82 @@
import { useState } from 'react'
import { AlertTriangle, Loader2 } from 'lucide-react'
import { Modal } from '@/components/common/Modal'
import type { EscalateSessionRequest } from '@/types/ai-session'
interface EscalateModalProps {
open: boolean
onClose: () => void
onEscalate: (data: EscalateSessionRequest) => Promise<unknown>
isProcessing: boolean
hasPsaTicket: boolean
}
export function EscalateModal({ open, onClose, onEscalate, isProcessing, hasPsaTicket }: EscalateModalProps) {
const [reason, setReason] = useState('')
const handleSubmit = async () => {
if (!reason.trim() || reason.trim().length < 5) return
await onEscalate({ escalation_reason: reason.trim() })
setReason('')
onClose()
}
const handleClose = () => {
if (!isProcessing) {
setReason('')
onClose()
}
}
return (
<Modal isOpen={open} onClose={handleClose} title="Escalate Session" size="sm">
<div className="space-y-4">
<div className="flex items-start gap-3 rounded-xl border border-amber-400/20 bg-amber-400/5 p-3">
<AlertTriangle size={16} className="text-amber-400 shrink-0 mt-0.5" />
<p className="text-sm text-amber-400">
This will mark the session as requesting escalation. Team members will see it in their escalation queue and can pick it up with full context.
</p>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-foreground">
Why are you escalating?
</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="e.g. I've exhausted all networking diagnostics and suspect this is a firewall policy issue that requires senior admin access..."
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={4}
autoFocus
/>
<p className="mt-1 text-[0.625rem] text-[#5a6170]">
Minimum 5 characters. This will be shown to the engineer who picks up.
</p>
</div>
<div className="flex gap-2">
<button
onClick={handleClose}
disabled={isProcessing}
className="flex-1 rounded-lg bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2.5 text-sm font-medium text-foreground hover:border-[rgba(255,255,255,0.12)] transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={!reason.trim() || reason.trim().length < 5 || isProcessing}
className="flex-1 flex items-center justify-center gap-2 rounded-lg bg-amber-500/90 px-4 py-2.5 text-sm font-semibold text-[#101114] hover:bg-amber-500 active:scale-[0.97] disabled:opacity-40 transition-all"
>
{isProcessing ? (
<Loader2 size={14} className="animate-spin" />
) : (
<AlertTriangle size={14} />
)}
{hasPsaTicket ? 'Escalate & Update Ticket' : 'Escalate'}
</button>
</div>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,140 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { AlertTriangle, Clock, Hash, Ticket, Loader2, RefreshCw } from 'lucide-react'
import { aiSessionsApi } from '@/api'
import type { AISessionSummary } from '@/types/ai-session'
interface EscalationQueueProps {
onPickup?: (sessionId: string) => void
}
export function EscalationQueue({ onPickup }: EscalationQueueProps) {
const navigate = useNavigate()
const [sessions, setSessions] = useState<AISessionSummary[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const loadQueue = async () => {
setIsLoading(true)
setError(null)
try {
const data = await aiSessionsApi.getEscalationQueue()
setSessions(data)
} catch {
setError('Failed to load escalation queue')
} finally {
setIsLoading(false)
}
}
useEffect(() => {
loadQueue()
}, [])
const handlePickup = (sessionId: string) => {
if (onPickup) {
onPickup(sessionId)
} else {
navigate(`/pilot/${sessionId}?pickup=true`)
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 size={20} className="animate-spin text-muted-foreground" />
</div>
)
}
if (error) {
return (
<div className="py-12 text-center">
<p className="text-sm text-rose-400">{error}</p>
<button
onClick={loadQueue}
className="mt-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Try again
</button>
</div>
)
}
if (sessions.length === 0) {
return (
<div className="py-12 text-center">
<AlertTriangle size={24} className="mx-auto mb-2 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No sessions awaiting escalation</p>
<button
onClick={loadQueue}
className="mt-3 flex items-center gap-1.5 mx-auto text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw size={12} />
Refresh
</button>
</div>
)
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between px-1">
<h3 className="font-label text-[0.625rem] uppercase tracking-wider text-[#5a6170]">
Awaiting pickup ({sessions.length})
</h3>
<button
onClick={loadQueue}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw size={10} />
Refresh
</button>
</div>
{sessions.map((session) => (
<div key={session.id} className="glass-card p-4 space-y-3">
<div>
<p className="text-sm font-semibold text-foreground">
{session.problem_summary || 'Untitled session'}
</p>
{session.escalation_reason && (
<p className="mt-1 text-xs text-amber-400 line-clamp-2">
Reason: {session.escalation_reason}
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
{session.problem_domain && (
<span className="font-label rounded-md bg-primary/10 px-1.5 py-0.5 text-[0.5625rem] uppercase tracking-wider text-primary">
{session.problem_domain}
</span>
)}
<span className="flex items-center gap-1">
<Hash size={10} />
{session.step_count} steps
</span>
<span className="flex items-center gap-1">
<Clock size={10} />
{new Date(session.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
{session.psa_ticket_id && (
<span className="flex items-center gap-1 text-primary">
<Ticket size={10} />
#{session.psa_ticket_id}
</span>
)}
</div>
<button
onClick={() => handlePickup(session.id)}
className="w-full 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"
>
Pick Up Session
</button>
</div>
))}
</div>
)
}

View File

@@ -1,26 +1,30 @@
import { useState } from 'react'
import { CheckCircle2, ArrowUpRight } from 'lucide-react'
import { CheckCircle2, ArrowUpRight, Pause } from 'lucide-react'
import { EscalateModal } from './EscalateModal'
import type { ResolveSessionRequest, EscalateSessionRequest, SessionDocumentation } from '@/types/ai-session'
interface FlowPilotActionBarProps {
canResolve: boolean
canEscalate: boolean
isProcessing: boolean
hasPsaTicket?: boolean
onResolve: (data: ResolveSessionRequest) => Promise<SessionDocumentation>
onEscalate: (data: EscalateSessionRequest) => Promise<SessionDocumentation>
onPause?: () => Promise<void>
}
export function FlowPilotActionBar({
canResolve,
canEscalate,
isProcessing,
hasPsaTicket = false,
onResolve,
onEscalate,
onPause,
}: FlowPilotActionBarProps) {
const [showResolve, setShowResolve] = useState(false)
const [showEscalate, setShowEscalate] = useState(false)
const [resolutionSummary, setResolutionSummary] = useState('')
const [escalationReason, setEscalationReason] = useState('')
const [submitting, setSubmitting] = useState(false)
const handleResolve = async () => {
@@ -34,14 +38,14 @@ export function FlowPilotActionBar({
}
}
const handleEscalate = async () => {
if (!escalationReason.trim() || escalationReason.length < 5) return
setSubmitting(true)
try {
await onEscalate({ escalation_reason: escalationReason })
setShowEscalate(false)
} finally {
setSubmitting(false)
const handlePause = async () => {
if (onPause) {
setSubmitting(true)
try {
await onPause()
} finally {
setSubmitting(false)
}
}
}
@@ -61,13 +65,23 @@ export function FlowPilotActionBar({
Resolve
</button>
<button
onClick={() => { setShowEscalate(true); setShowResolve(false) }}
onClick={() => setShowEscalate(true)}
disabled={!canEscalate || isProcessing}
className="flex items-center gap-2 rounded-lg bg-amber-500/10 border border-amber-500/20 px-4 py-2 text-sm font-medium text-amber-400 hover:bg-amber-500/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
>
<ArrowUpRight size={16} />
Escalate
</button>
{onPause && (
<button
onClick={handlePause}
disabled={isProcessing || submitting}
className="flex items-center gap-2 rounded-lg bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.12)] disabled:opacity-40 disabled:pointer-events-none transition-colors ml-auto"
>
<Pause size={16} />
Pause
</button>
)}
</div>
{/* Resolve modal */}
@@ -104,37 +118,13 @@ export function FlowPilotActionBar({
)}
{/* Escalate modal */}
{showEscalate && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="glass-card-static w-full max-w-lg p-6">
<h3 className="font-heading text-lg font-semibold text-foreground mb-1">Escalate Session</h3>
<p className="text-sm text-muted-foreground mb-4">Explain why this needs escalation. FlowPilot will package the context for the next engineer.</p>
<textarea
value={escalationReason}
onChange={(e) => setEscalationReason(e.target.value)}
placeholder="Why does this need to be escalated?"
className="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={4}
autoFocus
/>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={() => setShowEscalate(false)}
className="rounded-lg px-4 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
<button
onClick={handleEscalate}
disabled={escalationReason.length < 5 || submitting}
className="rounded-lg bg-amber-500/20 border border-amber-500/30 px-4 py-2 text-sm font-medium text-amber-400 hover:bg-amber-500/30 disabled:opacity-50 transition-colors"
>
{submitting ? 'Escalating...' : 'Escalate Session'}
</button>
</div>
</div>
</div>
)}
<EscalateModal
open={showEscalate}
onClose={() => setShowEscalate(false)}
onEscalate={onEscalate}
isProcessing={isProcessing || submitting}
hasPsaTicket={hasPsaTicket}
/>
</>
)
}

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>
)
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,173 @@
import { useState } from 'react'
import { ArrowRight, ChevronDown, ChevronRight, MessageSquare } from 'lucide-react'
interface EscalationPackage {
original_user_id?: string
problem_summary?: string
escalation_reason?: string
steps_tried?: Array<{
step_type?: string
description?: string
response?: string
}>
steps_ruled_out?: string[]
remaining_hypotheses?: string[]
suggested_next_steps?: string[]
confidence_at_escalation?: number
}
interface SessionBriefingProps {
escalationPackage: EscalationPackage
originalEngineerName?: string
onContinue: () => void
onFresh: (context: string) => void
isProcessing: boolean
}
export function SessionBriefing({
escalationPackage,
originalEngineerName,
onContinue,
onFresh,
isProcessing,
}: SessionBriefingProps) {
const [showSteps, setShowSteps] = useState(false)
const [freshMode, setFreshMode] = useState(false)
const [freshContext, setFreshContext] = useState('')
const pkg = escalationPackage
return (
<div className="glass-card-static border-l-2 border-l-amber-500 p-5 space-y-4">
<div>
<h3 className="font-heading text-base font-semibold text-foreground">
Escalation from {originalEngineerName || 'another engineer'}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
Review the briefing below, then choose how to proceed.
</p>
</div>
{/* Problem */}
{pkg.problem_summary && (
<div>
<h4 className="font-label text-[0.625rem] uppercase tracking-wider text-[#5a6170] mb-1">Problem</h4>
<p className="text-sm text-foreground">{pkg.problem_summary}</p>
</div>
)}
{/* Escalation reason */}
{pkg.escalation_reason && (
<div>
<h4 className="font-label text-[0.625rem] uppercase tracking-wider text-[#5a6170] mb-1">Why escalated</h4>
<p className="text-sm text-amber-400">{pkg.escalation_reason}</p>
</div>
)}
{/* Steps taken (collapsible) */}
{pkg.steps_tried && pkg.steps_tried.length > 0 && (
<div>
<button
onClick={() => setShowSteps(!showSteps)}
className="flex items-center gap-1.5 font-label text-[0.625rem] uppercase tracking-wider text-[#5a6170] hover:text-foreground transition-colors"
>
{showSteps ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
Steps taken ({pkg.steps_tried.length})
</button>
{showSteps && (
<div className="mt-2 space-y-1.5">
{pkg.steps_tried.map((step, i) => (
<div key={i} className="rounded-lg bg-card/50 px-3 py-2 text-xs">
<p className="text-foreground">{i + 1}. {step.description}</p>
{step.response && (
<p className="mt-0.5 text-primary"> {step.response}</p>
)}
</div>
))}
</div>
)}
</div>
)}
{/* Remaining hypotheses */}
{pkg.remaining_hypotheses && pkg.remaining_hypotheses.length > 0 && (
<div>
<h4 className="font-label text-[0.625rem] uppercase tracking-wider text-[#5a6170] mb-1">Remaining hypotheses</h4>
<ul className="space-y-1">
{pkg.remaining_hypotheses.map((h, i) => (
<li key={i} className="text-sm text-foreground flex items-start gap-2">
<span className="text-primary mt-0.5"></span>
{h}
</li>
))}
</ul>
</div>
)}
{/* Suggested next steps */}
{pkg.suggested_next_steps && pkg.suggested_next_steps.length > 0 && (
<div>
<h4 className="font-label text-[0.625rem] uppercase tracking-wider text-[#5a6170] mb-1">Suggested next steps</h4>
<ul className="space-y-1">
{pkg.suggested_next_steps.map((s, i) => (
<li key={i} className="text-sm text-foreground flex items-start gap-2">
<span className="text-emerald-400 mt-0.5"></span>
{s}
</li>
))}
</ul>
</div>
)}
{/* Action buttons */}
{!freshMode ? (
<div className="flex gap-2 pt-2">
<button
onClick={onContinue}
disabled={isProcessing}
className="flex-1 flex items-center justify-center gap-2 rounded-lg bg-gradient-brand px-4 py-2.5 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] disabled:opacity-40 transition-all"
>
<ArrowRight size={14} />
Continue Where They Left Off
</button>
<button
onClick={() => setFreshMode(true)}
disabled={isProcessing}
className="flex-1 flex items-center justify-center gap-2 rounded-lg bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2.5 text-sm font-medium text-foreground hover:border-[rgba(255,255,255,0.12)] disabled:opacity-40 transition-colors"
>
<MessageSquare size={14} />
Start Fresh With Context
</button>
</div>
) : (
<div className="space-y-3 pt-2">
<textarea
value={freshContext}
onChange={(e) => setFreshContext(e.target.value)}
placeholder="What additional information do you have, or what would you like to investigate first?"
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={3}
autoFocus
/>
<div className="flex gap-2">
<button
onClick={() => setFreshMode(false)}
disabled={isProcessing}
className="rounded-lg bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 text-sm font-medium text-foreground hover:border-[rgba(255,255,255,0.12)] disabled:opacity-40 transition-colors"
>
Back
</button>
<button
onClick={() => freshContext.trim() && onFresh(freshContext.trim())}
disabled={!freshContext.trim() || isProcessing}
className="flex-1 flex items-center justify-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] disabled:opacity-40 transition-all"
>
<ArrowRight size={14} />
Start Diagnosis
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,15 +1,111 @@
import { FileText, Clock, CheckCircle2, ArrowUpRight, Star } from 'lucide-react'
import { useState } from 'react'
import { FileText, Clock, CheckCircle2, ArrowUpRight, Star, AlertTriangle, Loader2, RefreshCw, Info } from 'lucide-react'
import { aiSessionsApi } from '@/api'
import { toast } from '@/lib/toast'
import type { SessionDocumentation } from '@/types/ai-session'
interface SessionDocViewProps {
documentation: SessionDocumentation
onRate?: (rating: number) => void
currentRating?: number | null
psaPushStatus?: string | null
psaPushError?: string | null
memberMappingWarning?: string | null
sessionId?: string
ticketId?: string | null
}
export function SessionDocView({ documentation, onRate, currentRating }: SessionDocViewProps) {
export function SessionDocView({
documentation,
onRate,
currentRating,
psaPushStatus,
psaPushError,
memberMappingWarning,
sessionId,
ticketId,
}: SessionDocViewProps) {
const [retrying, setRetrying] = useState(false)
const [currentPushStatus, setCurrentPushStatus] = useState(psaPushStatus)
const [currentPushError, setCurrentPushError] = useState(psaPushError)
const handleRetry = async () => {
if (!sessionId) return
setRetrying(true)
try {
const result = await aiSessionsApi.retryPsaPush(sessionId)
setCurrentPushStatus(result.psa_push_status)
setCurrentPushError(result.psa_push_error)
if (result.psa_push_status === 'sent') {
toast.success('Documentation pushed to ticket successfully')
}
} catch {
toast.error('Retry failed')
} finally {
setRetrying(false)
}
}
return (
<div className="space-y-5">
{/* PSA Push Status */}
{currentPushStatus && currentPushStatus !== 'no_psa' && (
<div
className={`rounded-xl border px-4 py-3 flex items-center gap-3 ${
currentPushStatus === 'sent'
? 'border-emerald-400/20 bg-emerald-400/5'
: currentPushStatus === 'pending_retry'
? 'border-amber-400/20 bg-amber-400/5'
: 'border-rose-500/20 bg-rose-500/5'
}`}
>
{currentPushStatus === 'sent' && (
<>
<CheckCircle2 size={16} className="text-emerald-400 shrink-0" />
<span className="text-sm text-emerald-400">
Documentation pushed to ticket {ticketId ? `#${ticketId}` : ''}
</span>
</>
)}
{currentPushStatus === 'pending_retry' && (
<>
<Loader2 size={16} className="text-amber-400 shrink-0 animate-spin" />
<span className="text-sm text-amber-400">
Documentation queued for push will sync shortly
</span>
</>
)}
{currentPushStatus === 'failed' && (
<>
<AlertTriangle size={16} className="text-rose-500 shrink-0" />
<div className="flex-1">
<span className="text-sm text-rose-500">
Failed to push to ticket{currentPushError ? `${currentPushError}` : ''}
</span>
</div>
<button
onClick={handleRetry}
disabled={retrying}
className="flex items-center gap-1.5 rounded-lg bg-rose-500/10 px-3 py-1.5 text-xs font-medium text-rose-500 hover:bg-rose-500/20 transition-colors disabled:opacity-50"
>
{retrying ? <Loader2 size={12} className="animate-spin" /> : <RefreshCw size={12} />}
Retry
</button>
</>
)}
</div>
)}
{/* Member mapping warning */}
{memberMappingWarning && (
<div className="rounded-xl border border-blue-400/20 bg-blue-400/5 px-4 py-3 flex items-start gap-3">
<Info size={16} className="text-blue-400 shrink-0 mt-0.5" />
<span className="text-sm text-blue-400">
Time entry was not created {memberMappingWarning} Session timing is included in the ticket note.
</span>
</div>
)}
{/* Header */}
<div className="glass-card-static p-5">
<div className="flex items-start gap-3">

View File

@@ -0,0 +1,106 @@
import { ExternalLink, Cpu, Building2 } from 'lucide-react'
interface TicketData {
ticket?: {
id?: number | string
summary?: string
status?: string
priority?: string
board?: string
}
company?: {
name?: string
}
configurations?: Array<{
device_identifier?: string
type?: string
ip_address?: string
}>
}
interface SessionTicketCardProps {
ticketId: string
ticketData: TicketData | null
siteUrl?: string
}
export function SessionTicketCard({ ticketId, ticketData, siteUrl }: SessionTicketCardProps) {
const ticket = ticketData?.ticket
const company = ticketData?.company
const configs = ticketData?.configurations
const ticketUrl = siteUrl
? `${siteUrl}/v4_6_release/services/system_io/Service/fv_sr100_request.rails?service_recid=${ticketId}`
: null
return (
<div className="rounded-xl border border-primary/20 bg-primary/5 p-3 space-y-2">
<div className="flex items-start justify-between">
<h4 className="font-label text-[0.625rem] uppercase tracking-wider text-[#5a6170]">
Linked Ticket
</h4>
{ticketUrl && (
<a
href={ticketUrl}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-primary transition-colors"
title="Open in ConnectWise"
>
<ExternalLink size={12} />
</a>
)}
</div>
<p className="text-sm font-semibold text-foreground">
<span className="text-primary">#{ticketId}</span>
{ticket?.summary && (
<span> {ticket.summary}</span>
)}
</p>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
{company?.name && (
<span className="flex items-center gap-1">
<Building2 size={10} />
{company.name}
</span>
)}
{ticket?.priority && (
<>
<span className="text-[#5a6170]">&bull;</span>
<span>{ticket.priority}</span>
</>
)}
{ticket?.status && (
<>
<span className="text-[#5a6170]">&bull;</span>
<span>{ticket.status}</span>
</>
)}
</div>
{configs && configs.length > 0 && (
<div className="border-t border-border/50 pt-2 mt-2">
<p className="font-label text-[0.5625rem] uppercase tracking-wider text-[#5a6170] mb-1">
Devices
</p>
<div className="space-y-0.5">
{configs.slice(0, 3).map((cfg, i) => (
<div key={i} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Cpu size={10} />
<span>{cfg.device_identifier}</span>
{cfg.type && <span className="text-[#5a6170]">({cfg.type})</span>}
</div>
))}
{configs.length > 3 && (
<p className="text-[0.625rem] text-[#5a6170]">
+{configs.length - 3} more
</p>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -6,3 +6,7 @@ export { FlowPilotActionBar } from './FlowPilotActionBar'
export { ConfidenceIndicator } from './ConfidenceIndicator'
export { SessionDocView } from './SessionDocView'
export { AISessionListItem } from './AISessionListItem'
export { SessionTicketCard } from './SessionTicketCard'
export { EscalateModal } from './EscalateModal'
export { EscalationQueue } from './EscalationQueue'
export { SessionBriefing } from './SessionBriefing'

View File

@@ -3,7 +3,7 @@ import { useLocation } from 'react-router-dom'
import {
LayoutGrid, Network, Wrench, Clock, FileOutput, BarChart3,
Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText,
BookOpen, Lightbulb, Code2, Library, Brain, WandSparkles, Sparkles,
BookOpen, Lightbulb, Code2, Library, Brain, WandSparkles, Sparkles, AlertTriangle,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
@@ -86,6 +86,7 @@ export function Sidebar() {
<NavItem href="/pilot" icon={Sparkles} label="New Session" iconColor={NAV_COLORS.dashboard} collapsed />
<NavItem href="/" icon={LayoutGrid} label="Dashboard" iconColor={NAV_COLORS.dashboard} collapsed />
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={stats?.active_count || undefined} iconColor={NAV_COLORS.sessions} collapsed />
<NavItem href="/escalations" icon={AlertTriangle} label="Escalations" iconColor="text-amber-400" collapsed />
<NavItem href="/trees" icon={Network} label="All Flows" matchPaths={['/trees', '/flows']} iconColor={NAV_COLORS.flows} collapsed />
<NavItem href="/assistant" icon={Brain} label="FlowPilot" iconColor={NAV_COLORS.flowPilot} collapsed />
<NavItem href="/scripts" icon={Code2} label="Script Library" iconColor={NAV_COLORS.scripts} collapsed />
@@ -135,6 +136,7 @@ export function Sidebar() {
Resolve
</div>
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={stats?.active_count || undefined} iconColor={NAV_COLORS.sessions} />
<NavItem href="/escalations" icon={AlertTriangle} label="Escalations" iconColor="text-amber-400" />
<NavItem
href="/trees"
icon={Network}

View File

@@ -12,11 +12,14 @@ type Mode = 'search' | 'manual'
interface Props {
open: boolean
onClose: () => void
sessionId: string
onLinked: (ticketId: string, ticket: PSATicketInfo) => void
/** Legacy session linking mode — pass sessionId + onLinked */
sessionId?: string
onLinked?: (ticketId: string, ticket: PSATicketInfo) => void
/** Selection-only mode — pass onSelect instead. Returns selected ticket without linking. */
onSelect?: (ticketId: string, ticket: PSATicketInfo) => void
}
export function TicketPickerModal({ open, onClose, sessionId, onLinked }: Props) {
export function TicketPickerModal({ open, onClose, sessionId, onLinked, onSelect }: Props) {
const [mode, setMode] = useState<Mode>('search')
// Search mode state
@@ -138,11 +141,22 @@ export function TicketPickerModal({ open, onClose, sessionId, onLinked }: Props)
const handleLink = async () => {
if (!selectedTicket || !selectedTicketId) return
// Selection-only mode — return ticket data without linking
if (onSelect) {
onSelect(selectedTicketId, selectedTicket)
handleReset()
onClose()
return
}
// Legacy session linking mode
if (!sessionId) return
setIsLinking(true)
setError(null)
try {
await sessionPsaApi.linkTicket(sessionId, selectedTicketId)
onLinked(selectedTicketId, selectedTicket)
onLinked?.(selectedTicketId, selectedTicket)
handleReset()
} catch (err: unknown) {
const message =
@@ -195,7 +209,7 @@ export function TicketPickerModal({ open, onClose, sessionId, onLinked }: Props)
}
return (
<Modal isOpen={open} onClose={handleClose} title="Link ConnectWise Ticket" size="sm">
<Modal isOpen={open} onClose={handleClose} title={onSelect ? 'Select ConnectWise Ticket' : 'Link ConnectWise Ticket'} size="sm">
<div className="space-y-4">
{/* Mode tabs */}
<div className="flex gap-1 rounded-lg bg-white/[0.03] p-1">
@@ -411,7 +425,7 @@ export function TicketPickerModal({ open, onClose, sessionId, onLinked }: Props)
loading={isLinking}
>
<Ticket className="h-4 w-4" />
Link This Ticket
{onSelect ? 'Select This Ticket' : 'Link This Ticket'}
</Button>
</div>
</div>

View File

@@ -27,6 +27,8 @@ export interface UseFlowPilotSession {
respondToStep: (response: StepResponseRequest) => Promise<void>
resolveSession: (data: ResolveSessionRequest) => Promise<SessionDocumentation>
escalateSession: (data: EscalateSessionRequest) => Promise<SessionDocumentation>
pauseSession: () => Promise<void>
resumeOwnSession: () => Promise<void>
rateSession: (rating: number, feedback?: string) => Promise<void>
loadSession: (sessionId: string) => Promise<void>
@@ -37,6 +39,9 @@ export interface UseFlowPilotSession {
// Post-close
documentation: SessionDocumentation | null
psaPushStatus: string | null
psaPushError: string | null
memberMappingWarning: string | null
}
export function useFlowPilotSession(): UseFlowPilotSession {
@@ -47,6 +52,9 @@ export function useFlowPilotSession(): UseFlowPilotSession {
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [documentation, setDocumentation] = useState<SessionDocumentation | null>(null)
const [psaPushStatus, setPsaPushStatus] = useState<string | null>(null)
const [psaPushError, setPsaPushError] = useState<string | null>(null)
const [memberMappingWarning, setMemberMappingWarning] = useState<string | null>(null)
const startSession = useCallback(async (intake: AISessionCreateRequest) => {
setIsLoading(true)
@@ -73,6 +81,9 @@ export function useFlowPilotSession(): UseFlowPilotSession {
resolution_action: null,
escalation_reason: null,
session_feedback: null,
psa_ticket_id: intake.psa_ticket_id ?? null,
psa_connection_id: intake.psa_connection_id ?? null,
ticket_data: null,
steps: [firstStep],
})
setAllSteps([firstStep])
@@ -120,6 +131,9 @@ export function useFlowPilotSession(): UseFlowPilotSession {
const result = await aiSessionsApi.resolveSession(session.id, data)
setSession(prev => prev ? { ...prev, status: 'resolved' } : null)
setDocumentation(result.documentation)
setPsaPushStatus(result.psa_push_status)
setPsaPushError(result.psa_push_error)
setMemberMappingWarning(result.member_mapping_warning)
setCurrentStep(null)
toast.success('Session resolved')
return result.documentation
@@ -139,6 +153,9 @@ export function useFlowPilotSession(): UseFlowPilotSession {
const result = await aiSessionsApi.escalateSession(session.id, data)
setSession(prev => prev ? { ...prev, status: 'escalated' } : null)
setDocumentation(result.documentation)
setPsaPushStatus(result.psa_push_status)
setPsaPushError(result.psa_push_error)
setMemberMappingWarning(result.member_mapping_warning)
setCurrentStep(null)
toast.success('Session escalated')
return result.documentation
@@ -151,6 +168,28 @@ export function useFlowPilotSession(): UseFlowPilotSession {
}
}, [session])
const pauseSession = useCallback(async () => {
if (!session) return
try {
await aiSessionsApi.pauseSession(session.id)
setSession(prev => prev ? { ...prev, status: 'paused' } : null)
toast.success('Session paused')
} catch {
toast.error('Failed to pause session')
}
}, [session])
const resumeOwnSession = useCallback(async () => {
if (!session) return
try {
await aiSessionsApi.resumeSession(session.id)
setSession(prev => prev ? { ...prev, status: 'active' } : null)
toast.success('Session resumed')
} catch {
toast.error('Failed to resume session')
}
}, [session])
const rateSession = useCallback(async (rating: number, feedback?: string) => {
if (!session) return
try {
@@ -169,7 +208,12 @@ export function useFlowPilotSession(): UseFlowPilotSession {
const detail = await aiSessionsApi.getSession(sessionId)
setSession(detail)
setAllSteps(detail.steps)
setCurrentStep(detail.status === 'active' ? detail.steps[detail.steps.length - 1] ?? null : null)
// Set current step for active and paused sessions (paused can be resumed)
setCurrentStep(
detail.status === 'active' || detail.status === 'paused'
? detail.steps[detail.steps.length - 1] ?? null
: null
)
if (detail.status === 'resolved' || detail.status === 'escalated') {
const doc = await aiSessionsApi.getDocumentation(sessionId)
@@ -199,11 +243,16 @@ export function useFlowPilotSession(): UseFlowPilotSession {
respondToStep,
resolveSession,
escalateSession,
pauseSession,
resumeOwnSession,
rateSession,
loadSession,
isActive,
canResolve,
canEscalate,
documentation,
psaPushStatus,
psaPushError,
memberMappingWarning,
}
}

View 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>
)
}

View File

@@ -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>

View File

@@ -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

View File

@@ -46,6 +46,7 @@ const ScriptManagePage = lazy(() => import('@/pages/ScriptManagePage'))
const AssistantChatPage = lazy(() => import('@/pages/AssistantChatPage'))
const FlowAssistPage = lazy(() => import('@/pages/FlowAssistPage'))
const FlowPilotSessionPage = lazy(() => import('@/pages/FlowPilotSessionPage'))
const EscalationQueuePage = lazy(() => import('@/pages/EscalationQueuePage'))
const KBAcceleratorPage = lazy(() => import('@/pages/KBAcceleratorPage'))
const GuidesHubPage = lazy(() => import('@/pages/GuidesHubPage'))
const GuideDetailPage = lazy(() => import('@/pages/GuideDetailPage'))
@@ -172,6 +173,7 @@ export const router = sentryCreateBrowserRouter([
{ path: 'flow-assist', element: page(FlowAssistPage) },
{ path: 'pilot', element: page(FlowPilotSessionPage) },
{ path: 'pilot/:sessionId', element: page(FlowPilotSessionPage) },
{ path: 'escalations', element: page(EscalationQueuePage) },
{ path: 'guides', element: page(GuidesHubPage) },
{ path: 'guides/:slug', element: page(GuideDetailPage) },
// Admin routes

View File

@@ -17,6 +17,7 @@ export interface AISessionCreateResponse {
matched_flow_name: string | null
match_score: number | null
first_step: AISessionStepResponse
psa_context_status: string | null // "loaded" | "unavailable" | null
}
// ── Step interaction ──
@@ -95,6 +96,9 @@ export interface SessionCloseResponse {
session_id: string
status: string
documentation: SessionDocumentation
psa_push_status: string // "sent" | "pending_retry" | "no_psa" | "failed"
psa_push_error: string | null
member_mapping_warning: string | null
}
export interface RateSessionRequest {
@@ -113,10 +117,17 @@ export interface AISessionSummary {
confidence_tier: string
step_count: number
session_rating: number | null
psa_ticket_id: string | null
escalation_reason: string | null
created_at: string
resolved_at: string | null
}
export interface PickupSessionRequest {
resume_mode: 'continue' | 'fresh'
additional_context?: string
}
export interface AISessionDetail extends AISessionSummary {
intake_content: Record<string, unknown>
matched_flow_id: string | null
@@ -125,5 +136,8 @@ export interface AISessionDetail extends AISessionSummary {
resolution_action: string | null
escalation_reason: string | null
session_feedback: string | null
psa_ticket_id: string | null
psa_connection_id: string | null
ticket_data: Record<string, unknown> | null
steps: AISessionStepResponse[]
}

View File

@@ -121,3 +121,13 @@ export interface AutoMatchResult {
matched: PsaMemberMappingResponse[]
unmatched_users: number
}
export interface FlowpilotSettings {
auto_push: boolean
auto_time_entry: boolean
time_rounding: string // "15min" | "30min" | "exact" | "none"
note_visibility: string // "internal" | "both"
include_diagnostic_steps: boolean
prompt_status_on_resolution: boolean
prompt_status_on_escalation: boolean
}