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:
82
frontend/src/components/flowpilot/EscalateModal.tsx
Normal file
82
frontend/src/components/flowpilot/EscalateModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
140
frontend/src/components/flowpilot/EscalationQueue.tsx
Normal file
140
frontend/src/components/flowpilot/EscalationQueue.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
173
frontend/src/components/flowpilot/SessionBriefing.tsx
Normal file
173
frontend/src/components/flowpilot/SessionBriefing.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
106
frontend/src/components/flowpilot/SessionTicketCard.tsx
Normal file
106
frontend/src/components/flowpilot/SessionTicketCard.tsx
Normal 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]">•</span>
|
||||
<span>{ticket.priority}</span>
|
||||
</>
|
||||
)}
|
||||
{ticket?.status && (
|
||||
<>
|
||||
<span className="text-[#5a6170]">•</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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user