From 8e9d22e0e0095b2e78d1dbaf39ebaf9b66ba18e6 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Mon, 27 Apr 2026 21:06:14 -0400 Subject: [PATCH] feat(escalations): magic-moment handoff-context screen on pickup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the dedicated 4-section handoff-context view that renders BEFORE the FlowPilot session for senior techs picking up an escalated session, then dissolves on "Start here". This is the wedge's demonstrable magic moment — what the GTM Loom records. - HandoffContextScreen.tsx: pure presentational, takes a HandoffResponse plus onStartHere / onDismiss callbacks. Sections: header (problem summary, domain, step count, escalated-time, priority badge), "What's been tried" (engineer notes + step-count affordance), "AI assessment" (likely_cause / suggested_steps / confidence badge), Start here CTA. Confidence badge accepts both numeric (0..1) and string ("low"/"medium"/"high") shapes — backend currently emits the latter. Renders an explicit "assessment unavailable" branch when ai_assessment_data is null (the 5s timeout from 9bdd995 fired). Honors prefers-reduced-motion (animate-fade-in vs animate-slide-up). ARIA dialog + focus on the primary CTA. Esc dismisses when used as a re-openable overlay; pre-claim, Start here is the only exit. - FlowPilotSessionPage.tsx: on /pilot/:id?pickup=true, fetch the handoff list via handoffsApi.listHandoffs (account-scoped via RLS, no claim required) and find the latest unclaimed escalate handoff. If found, render the magic-moment screen and skip the regular loadSession (the senior isn't yet escalated_to_id, so GET would 404). Start here calls claimHandoff, drops the pickup query param, dismisses the screen — the existing loadSession effect then fires because the senior is now escalated_to_id. A "Context" toolbar button on active sessions re-opens the screen as a dismissible overlay (visible only when the senior arrived via the magic-moment flow this session — handoff lookup on demand). Verified end-to-end against the running dev stack: listHandoffs returns the unclaimed handoff with full payload; claim flips session status from escalated → active; subsequent GET succeeds. tsc -b clean. Defers (TODO followups): suggested-step chips below the chat input that prefill on click (requires threading through to FlowPilotMessageBar); snapshot expansion to include the recent diagnostic steps pre-claim; toolbar Context button on sessions where the senior didn't arrive via magic-moment. Co-Authored-By: Claude Opus 4.7 --- .../flowpilot/HandoffContextScreen.tsx | 308 ++++++++++++++++++ frontend/src/components/flowpilot/index.ts | 1 + frontend/src/pages/FlowPilotSessionPage.tsx | 142 +++++++- 3 files changed, 447 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/flowpilot/HandoffContextScreen.tsx diff --git a/frontend/src/components/flowpilot/HandoffContextScreen.tsx b/frontend/src/components/flowpilot/HandoffContextScreen.tsx new file mode 100644 index 00000000..8c055cc7 --- /dev/null +++ b/frontend/src/components/flowpilot/HandoffContextScreen.tsx @@ -0,0 +1,308 @@ +import { useEffect, useMemo, useRef } from 'react' +import { + AlertTriangle, + ArrowRight, + Brain, + Clock, + FileText, + Hash, + Sparkles, + Target, + X, +} from 'lucide-react' +import type { HandoffResponse } from '@/types/branching' +import { cn } from '@/lib/utils' +import { timeAgo } from '@/lib/timeAgo' + +// Magic-moment handoff-context screen. Renders BEFORE the FlowPilot session +// view when a senior tech picks up an escalated session, then dissolves on +// "Start here". Re-openable via toolbar in FlowPilotSessionPage. +// +// Four sections per the design plan: +// 1. Problem summary (top, Bricolage h2) +// 2. What's been tried (left column) — engineer notes + step count. +// Full step detail isn't in the handoff snapshot today (snapshot = +// problem_summary, problem_domain, status, step_count, confidence_tier +// per HandoffManager._generate_snapshot); we surface what's there and +// promise the timeline post-pickup. Snapshot expansion is a follow-up. +// 3. AI assessment (right column) — likely_cause / suggested_steps / +// confidence. Renders gracefully when ai_assessment is null (the 5s +// timeout from commit 9bdd995 fired). +// 4. Start here (primary CTA, electric-blue, ≥44px) — claims the handoff +// and dissolves the screen. + +type ConfidenceTier = 'low' | 'medium' | 'high' | string + +interface HandoffContextScreenProps { + handoff: HandoffResponse + onStartHere: () => Promise | void + onDismiss?: () => void + // When true, renders an "X" close affordance in the corner. Used when the + // screen is re-opened from the FlowPilot toolbar (post-claim re-read). + dismissible?: boolean + isProcessing?: boolean +} + +function ConfidenceBadge({ value }: { value: number | string | null | undefined }) { + if (value === null || value === undefined || value === '') return null + // Numeric (0..1) or string tier + let tier: ConfidenceTier = 'medium' + let label = String(value) + if (typeof value === 'number') { + tier = value >= 0.7 ? 'high' : value >= 0.4 ? 'medium' : 'low' + label = `${Math.round(value * 100)}%` + } else { + const s = String(value).toLowerCase() + if (s === 'low' || s === 'medium' || s === 'high') tier = s + label = s.charAt(0).toUpperCase() + s.slice(1) + } + const tone = + tier === 'high' + ? 'bg-success-dim text-success border border-success/20' + : tier === 'low' + ? 'bg-warning-dim text-warning border border-warning/20' + : 'bg-accent-dim text-accent-text border border-accent/20' + return ( + + {label} + + ) +} + +export function HandoffContextScreen({ + handoff, + onStartHere, + onDismiss, + dismissible = false, + isProcessing = false, +}: HandoffContextScreenProps) { + const startBtnRef = useRef(null) + + const prefersReducedMotion = useMemo(() => { + if (typeof window === 'undefined' || !window.matchMedia) return false + return window.matchMedia('(prefers-reduced-motion: reduce)').matches + }, []) + + // Esc dismisses when the screen is re-opened post-claim (dismissible mode). + // Pre-claim, Esc has no escape hatch — they must Start here or back out via + // browser nav. + useEffect(() => { + if (!dismissible || !onDismiss) return + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onDismiss() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [dismissible, onDismiss]) + + // Focus the primary CTA on mount so keyboard users can hit Enter. + useEffect(() => { + startBtnRef.current?.focus() + }, []) + + const snapshot = handoff.snapshot as Record + const problemSummary = + (snapshot.problem_summary as string | undefined) || 'Untitled session' + const problemDomain = snapshot.problem_domain as string | undefined + const stepCount = (snapshot.step_count as number | undefined) ?? 0 + const confidenceTier = snapshot.confidence_tier as string | undefined + + const assessment = handoff.ai_assessment_data + const likelyCause = assessment?.likely_cause + const suggestedSteps = assessment?.suggested_steps ?? [] + const assessmentConfidence = assessment?.confidence + const assessmentText = handoff.ai_assessment + + const enterClass = prefersReducedMotion ? 'animate-fade-in' : 'animate-slide-up' + + return ( +
+ {/* Header */} +
+ + + +
+

+ Escalation handoff +

+

+ {problemSummary} +

+
+ {problemDomain && ( + + {problemDomain} + + )} + + + {stepCount} {stepCount === 1 ? 'step' : 'steps'} + + {confidenceTier && ( + + Session confidence: {confidenceTier} + + )} + + + Escalated {timeAgo(handoff.created_at)} + + {handoff.priority === 'elevated' && ( + + Elevated + + )} +
+
+ {dismissible && onDismiss && ( + + )} +
+ + {/* Two-column body */} +
+ {/* What's been tried */} +
+
+ +

+ What's been tried +

+
+ {handoff.engineer_notes ? ( +
+

+ Why they escalated +

+

+ {handoff.engineer_notes} +

+
+ ) : ( +

+ No notes from the original engineer. +

+ )} +
+ {stepCount}{' '} + diagnostic {stepCount === 1 ? 'step' : 'steps'} on record. Full + timeline opens when you start the session. +
+
+ + {/* AI assessment */} +
+
+
+ +

+ AI assessment +

+
+ +
+ + {!assessmentText && !likelyCause && suggestedSteps.length === 0 ? ( +
+ + + Assessment unavailable — model didn't respond in time. Pick up + the session to investigate directly. + +
+ ) : ( + <> + {likelyCause && ( +
+

+ Likely cause +

+

{likelyCause}

+
+ )} + {assessmentText && !likelyCause && ( +

+ {assessmentText} +

+ )} + {suggestedSteps.length > 0 && ( +
+

+ Suggested next steps +

+
    + {suggestedSteps.map((step, i) => ( +
  • + + {step} +
  • + ))} +
+
+ )} + + )} +
+
+ + {/* Start here CTA */} + {!dismissible && ( +
+

+ Picking up assigns this session to you and reactivates it. +

+ +
+ )} +
+ ) +} diff --git a/frontend/src/components/flowpilot/index.ts b/frontend/src/components/flowpilot/index.ts index 0cdb9db0..5556008d 100644 --- a/frontend/src/components/flowpilot/index.ts +++ b/frontend/src/components/flowpilot/index.ts @@ -11,6 +11,7 @@ export { EscalateModal } from './EscalateModal' export { EscalationQueue } from './EscalationQueue' export { EscalationMetricCard } from './EscalationMetricCard' export { SessionBriefing } from './SessionBriefing' +export { HandoffContextScreen } from './HandoffContextScreen' export { ProposalCard } from './ProposalCard' export { ProposalDetail } from './ProposalDetail' export { InSessionScriptGenerator } from './InSessionScriptGenerator' diff --git a/frontend/src/pages/FlowPilotSessionPage.tsx b/frontend/src/pages/FlowPilotSessionPage.tsx index e1ecd22e..c4fdec3b 100644 --- a/frontend/src/pages/FlowPilotSessionPage.tsx +++ b/frontend/src/pages/FlowPilotSessionPage.tsx @@ -3,7 +3,7 @@ import { useParams, useSearchParams, useLocation, useBlocker, useNavigate } from import { Sparkles, Loader2, AlertTriangle, CheckCircle2, ArrowUpRight, FileText, MoreHorizontal, Pause, X } from 'lucide-react' import { useFlowPilotSession } from '@/hooks/useFlowPilotSession' import { useBranching } from '@/hooks/useBranching' -import { FlowPilotIntake, FlowPilotSession, SessionBriefing } from '@/components/flowpilot' +import { FlowPilotIntake, FlowPilotSession, SessionBriefing, HandoffContextScreen } from '@/components/flowpilot' import { EscalateModal } from '@/components/flowpilot/EscalateModal' import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal' import { HandoffModal } from '@/components/session/HandoffModal' @@ -11,6 +11,7 @@ import { handoffsApi } from '@/api/handoffs' import { aiSessionsApi } from '@/api' import { integrationsApi } from '@/api/integrations' import type { PSATicketInfo } from '@/types/integrations' +import type { HandoffResponse } from '@/types/branching' import { toast } from '@/lib/toast' export default function FlowPilotSessionPage() { @@ -76,12 +77,95 @@ export default function FlowPilotSessionPage() { const [pickingUp, setPickingUp] = useState(false) - // Load existing session if ID in URL + // ── Magic-moment handoff-context screen ── + // When the senior arrives via /pilot/:id?pickup=true, the regular session + // GET 404s pre-claim (the senior isn't yet escalated_to_id). So we fetch + // the handoff list first (account-scoped via RLS, no claim required), find + // the most recent unclaimed escalate handoff, and render the magic-moment + // screen. "Start here" claims the handoff, then loadSession fires. + const [magicState, setMagicState] = useState<'inactive' | 'loading' | 'visible' | 'dismissed'>( + isPickup ? 'loading' : 'inactive', + ) + const [magicHandoff, setMagicHandoff] = useState(null) + const [overlayHandoff, setOverlayHandoff] = useState(null) + const [overlayLoading, setOverlayLoading] = useState(false) + const [claiming, setClaiming] = useState(false) + useEffect(() => { - if (sessionId && !fp.session) { + if (!isPickup || !sessionId || magicState !== 'loading') return + let cancelled = false + ;(async () => { + try { + const handoffs = await handoffsApi.listHandoffs(sessionId) + if (cancelled) return + // Newest unclaimed escalate handoff. listHandoffs orders desc by + // created_at on the backend, so .find() picks the latest. + const target = handoffs.find((h) => h.intent === 'escalate' && !h.claimed_by) + if (target) { + setMagicHandoff(target) + setMagicState('visible') + } else { + setMagicState('dismissed') + } + } catch { + if (cancelled) return + // Fall through to the legacy SessionBriefing path on failure. + setMagicState('dismissed') + } + })() + return () => { + cancelled = true + } + }, [isPickup, sessionId, magicState]) + + // Load existing session if ID in URL. Skip while the magic-moment screen is + // up — we don't have access to the session detail until claim. + useEffect(() => { + if (sessionId && !fp.session && magicState !== 'loading' && magicState !== 'visible') { fp.loadSession(sessionId) } - }, [sessionId]) // eslint-disable-line react-hooks/exhaustive-deps + }, [sessionId, magicState]) // eslint-disable-line react-hooks/exhaustive-deps + + const handleStartHere = async () => { + if (!sessionId || !magicHandoff) return + setClaiming(true) + try { + await handoffsApi.claimHandoff(sessionId, magicHandoff.id) + // Drop the pickup query param and dismiss the screen — the loadSession + // effect above will fire because magicState is no longer 'visible'. + setSearchParams({}) + setMagicState('dismissed') + } catch (e: unknown) { + const message = e instanceof Error ? e.message : 'Failed to pick up session' + toast.error(message) + } finally { + setClaiming(false) + } + } + + const openHandoffContextOverlay = async () => { + if (!sessionId) return + // Reuse the in-memory copy when we already loaded the handoff during + // pickup, otherwise fetch on demand. + if (magicHandoff) { + setOverlayHandoff(magicHandoff) + return + } + setOverlayLoading(true) + try { + const handoffs = await handoffsApi.listHandoffs(sessionId) + const target = handoffs.find((h) => h.intent === 'escalate') + if (target) { + setOverlayHandoff(target) + } else { + toast.info('No handoff context available for this session.') + } + } catch { + toast.error('Could not load handoff context') + } finally { + setOverlayLoading(false) + } + } // Load branches when session is branching useEffect(() => { @@ -133,6 +217,28 @@ export default function FlowPilotSessionPage() { } } + // Magic-moment handoff-context screen — shown before the senior tech claims + // an escalated session. Takes priority over session loading because the + // senior can't load the session detail until claim succeeds. + if (magicState === 'loading') { + return ( +
+ +
+ ) + } + if (magicState === 'visible' && magicHandoff) { + return ( +
+ +
+ ) + } + // Error state if (fp.error && !fp.session) { return ( @@ -273,6 +379,17 @@ export default function FlowPilotSessionPage() { <> {/* Desktop actions */}
+ {magicHandoff && ( + + )}