Replaces the legacy flowpilot_engine.escalate_session orchestration with
a single canonical path through HandoffManager. Every escalation now
creates a SessionHandoff row, fans out via the SSE bus, persists
AppNotification rows for the bell icon, dispatches to external channels
(Slack/Teams) via notify(), and emails per-user — regardless of whether
the call entered through /escalate (legacy URL) or /handoff (new URL).
The senior-pickup magic-moment screen now works end-to-end from the
EscalateModal bell-icon path the user just tested.
Backend
- HandoffCreateRequest gains optional target_user_id (the equivalent of
the legacy escalated_to_id field). Self-targeting rejected.
- HandoffManager.create_handoff handles intent='escalate' end-to-end:
sets escalation_reason + escalated_to_id, builds the legacy enhanced
AI escalation_package (Sonnet, lazy-imported from flowpilot_engine,
graceful fallback on failure), and merges handoff metadata into it.
Eager-loads session.steps and session.user via selectinload — required
by both the enhanced-package builder and notify() to avoid
MissingGreenlet on async lazy access.
- HandoffManager.finalize_escalation generates SessionDocumentation,
pushes documentation to PSA, and runs notify() — pre-commit so the
AppNotification rows persist atomically with the handoff.
- HandoffManager.dispatch_escalation_notifications keeps only the
fire-and-forget IO (bus publish, per-user emails) — runs post-commit.
Pulls engineer name via a separate User query rather than relying on
session.user lazy access.
- /handoff endpoint passes target_user_id through and calls
finalize_escalation pre-commit.
- /escalate endpoint is now a thin shim: owner-only session lookup,
HandoffManager.create_handoff(intent='escalate'), finalize_escalation,
commit, dispatch_escalation_notifications, return SessionCloseResponse
built from documentation + psa_result. flowpilot_engine.escalate_session
is no longer called by any endpoint.
- pickup_session accepts both 'requesting_escalation' (legacy in-flight
sessions) and 'escalated' (new canonical) so the migration is seamless
for sessions already in the queue.
- Escalation queue list and sidebar count now match either status.
Frontend
- useFlowPilotSession optimistic update flips status to 'escalated'
instead of 'requesting_escalation' so the page state matches the
unified backend response.
Verified end-to-end live: a fresh /escalate call from the junior produces
status='escalated', a SessionHandoff row, a SessionDocumentation, PSA
push attempted (no_psa for this test session), AND a bell-icon
AppNotification for the team admin with link
/pilot/{session_id}?pickup=true. Backend test suite: 1103 passed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
import { useState, useCallback } from 'react'
|
|
import { aiSessionsApi } from '@/api'
|
|
import type {
|
|
AISessionCreateRequest,
|
|
AISessionCreateResponse,
|
|
AISessionDetail,
|
|
AISessionStepResponse,
|
|
StepResponseRequest,
|
|
StepResponseResponse,
|
|
ResolveSessionRequest,
|
|
EscalateSessionRequest,
|
|
SessionDocumentation,
|
|
StatusUpdateRequest,
|
|
StatusUpdateResponse,
|
|
} from '@/types/ai-session'
|
|
import { toast } from '@/lib/toast'
|
|
|
|
export interface UseFlowPilotSession {
|
|
// State
|
|
session: AISessionDetail | null
|
|
currentStep: AISessionStepResponse | null
|
|
allSteps: AISessionStepResponse[]
|
|
isLoading: boolean
|
|
isProcessing: boolean
|
|
error: string | null
|
|
|
|
// Actions
|
|
startSession: (intake: AISessionCreateRequest) => Promise<void>
|
|
respondToStep: (response: StepResponseRequest) => Promise<void>
|
|
resolveSession: (data: ResolveSessionRequest) => Promise<SessionDocumentation | null>
|
|
escalateSession: (data: EscalateSessionRequest) => Promise<SessionDocumentation | null>
|
|
pauseSession: () => Promise<void>
|
|
resumeOwnSession: () => Promise<void>
|
|
abandonSession: () => Promise<void>
|
|
rateSession: (rating: number, feedback?: string) => Promise<void>
|
|
generateStatusUpdate: (data: StatusUpdateRequest) => Promise<StatusUpdateResponse>
|
|
loadSession: (sessionId: string) => Promise<void>
|
|
|
|
// Derived
|
|
isActive: boolean
|
|
canResolve: boolean
|
|
canEscalate: boolean
|
|
|
|
// Post-close
|
|
documentation: SessionDocumentation | null
|
|
psaPushStatus: string | null
|
|
psaPushError: string | null
|
|
memberMappingWarning: string | null
|
|
}
|
|
|
|
export function useFlowPilotSession(): UseFlowPilotSession {
|
|
const [session, setSession] = useState<AISessionDetail | null>(null)
|
|
const [currentStep, setCurrentStep] = useState<AISessionStepResponse | null>(null)
|
|
const [allSteps, setAllSteps] = useState<AISessionStepResponse[]>([])
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
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)
|
|
setError(null)
|
|
try {
|
|
const result: AISessionCreateResponse = await aiSessionsApi.createSession(intake)
|
|
const firstStep = result.first_step
|
|
|
|
setSession({
|
|
id: result.session_id,
|
|
session_type: 'guided',
|
|
title: null,
|
|
status: result.status,
|
|
intake_type: intake.intake_type,
|
|
intake_content: intake.intake_content,
|
|
problem_summary: result.problem_summary,
|
|
problem_domain: result.problem_domain,
|
|
confidence_tier: result.confidence_tier,
|
|
step_count: 1,
|
|
session_rating: null,
|
|
created_at: new Date().toISOString(),
|
|
resolved_at: null,
|
|
matched_flow_id: result.matched_flow_id,
|
|
match_score: result.match_score,
|
|
resolution_summary: null,
|
|
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],
|
|
conversation_messages: [],
|
|
pending_task_lane: null,
|
|
is_branching: false,
|
|
active_branch_id: null,
|
|
})
|
|
setAllSteps([firstStep])
|
|
setCurrentStep(firstStep)
|
|
} catch (e: unknown) {
|
|
// Prefer the backend's detail message over the generic axios status string
|
|
const axiosErr = e as { response?: { status?: number; data?: { detail?: unknown } } }
|
|
const detail = axiosErr?.response?.data?.detail
|
|
const message = typeof detail === 'string' ? detail : (e instanceof Error ? e.message : 'Failed to start session')
|
|
setError(message)
|
|
// Global axios interceptor already shows a toast for 5xx — skip duplicate
|
|
const status = axiosErr?.response?.status
|
|
if (!status || status < 500) {
|
|
toast.error(message)
|
|
}
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
const respondToStep = useCallback(async (response: StepResponseRequest) => {
|
|
if (!session) return
|
|
setIsProcessing(true)
|
|
setError(null)
|
|
try {
|
|
const result: StepResponseResponse = await aiSessionsApi.respondToStep(session.id, response)
|
|
|
|
setSession(prev => prev ? {
|
|
...prev,
|
|
status: result.status,
|
|
confidence_tier: result.confidence_tier,
|
|
step_count: prev.step_count + 1,
|
|
} : null)
|
|
|
|
if (result.next_step) {
|
|
setAllSteps(prev => [...prev, result.next_step!])
|
|
setCurrentStep(result.next_step)
|
|
}
|
|
} catch (e: unknown) {
|
|
const message = e instanceof Error ? e.message : 'Failed to process response'
|
|
setError(message)
|
|
toast.error(message)
|
|
} finally {
|
|
setIsProcessing(false)
|
|
}
|
|
}, [session])
|
|
|
|
const resolveSession = useCallback(async (data: ResolveSessionRequest): Promise<SessionDocumentation | null> => {
|
|
if (!session) throw new Error('No active session')
|
|
setIsProcessing(true)
|
|
try {
|
|
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
|
|
} catch (e: unknown) {
|
|
const message = e instanceof Error ? e.message : 'Failed to resolve session'
|
|
toast.error(message)
|
|
throw e
|
|
} finally {
|
|
setIsProcessing(false)
|
|
}
|
|
}, [session])
|
|
|
|
const escalateSession = useCallback(async (data: EscalateSessionRequest): Promise<SessionDocumentation | null> => {
|
|
if (!session) throw new Error('No active session')
|
|
setIsProcessing(true)
|
|
try {
|
|
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
|
|
} catch (e: unknown) {
|
|
const message = e instanceof Error ? e.message : 'Failed to escalate session'
|
|
toast.error(message)
|
|
throw e
|
|
} finally {
|
|
setIsProcessing(false)
|
|
}
|
|
}, [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 abandonSession = useCallback(async () => {
|
|
if (!session) return
|
|
try {
|
|
await aiSessionsApi.abandonSession(session.id)
|
|
setSession(prev => prev ? { ...prev, status: 'abandoned' } : null)
|
|
setCurrentStep(null)
|
|
toast.success('Session closed')
|
|
} catch {
|
|
toast.error('Failed to close session')
|
|
}
|
|
}, [session])
|
|
|
|
const rateSession = useCallback(async (rating: number, feedback?: string) => {
|
|
if (!session) return
|
|
try {
|
|
await aiSessionsApi.rateSession(session.id, { rating, feedback })
|
|
setSession(prev => prev ? { ...prev, session_rating: rating } : null)
|
|
toast.success('Thanks for your feedback!')
|
|
} catch {
|
|
toast.error('Failed to submit rating')
|
|
}
|
|
}, [session])
|
|
|
|
const generateStatusUpdate = useCallback(async (data: StatusUpdateRequest): Promise<StatusUpdateResponse> => {
|
|
if (!session) throw new Error('No active session')
|
|
try {
|
|
return await aiSessionsApi.generateStatusUpdate(session.id, data)
|
|
} catch (e: unknown) {
|
|
const message = e instanceof Error ? e.message : 'Failed to generate status update'
|
|
toast.error(message)
|
|
throw e
|
|
}
|
|
}, [session])
|
|
|
|
const loadSession = useCallback(async (sessionId: string) => {
|
|
setIsLoading(true)
|
|
setError(null)
|
|
try {
|
|
const detail = await aiSessionsApi.getSession(sessionId)
|
|
setSession(detail)
|
|
setAllSteps(detail.steps)
|
|
// 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)
|
|
setDocumentation(doc)
|
|
}
|
|
} catch (e: unknown) {
|
|
const message = e instanceof Error ? e.message : 'Failed to load session'
|
|
setError(message)
|
|
toast.error(message)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
const isActive = session?.status === 'active'
|
|
const canResolve = isActive && allSteps.length >= 1
|
|
const canEscalate = isActive && allSteps.length >= 1
|
|
|
|
return {
|
|
session,
|
|
currentStep,
|
|
allSteps,
|
|
isLoading,
|
|
isProcessing,
|
|
error,
|
|
startSession,
|
|
respondToStep,
|
|
resolveSession,
|
|
escalateSession,
|
|
pauseSession,
|
|
resumeOwnSession,
|
|
abandonSession,
|
|
rateSession,
|
|
generateStatusUpdate,
|
|
loadSession,
|
|
isActive,
|
|
canResolve,
|
|
canEscalate,
|
|
documentation,
|
|
psaPushStatus,
|
|
psaPushError,
|
|
memberMappingWarning,
|
|
}
|
|
}
|