feat(escalations): magic-moment handoff-context screen on pickup

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 21:06:14 -04:00
parent f65b65790c
commit 8e9d22e0e0
3 changed files with 447 additions and 4 deletions

View File

@@ -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> | 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 (
<span
className={cn(
'font-sans rounded-md px-1.5 py-0.5 text-[0.5625rem] uppercase tracking-wider',
tone,
)}
>
{label}
</span>
)
}
export function HandoffContextScreen({
handoff,
onStartHere,
onDismiss,
dismissible = false,
isProcessing = false,
}: HandoffContextScreenProps) {
const startBtnRef = useRef<HTMLButtonElement>(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<string, unknown>
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 (
<div
role="dialog"
aria-modal="true"
aria-labelledby="handoff-context-title"
className={cn(
'mx-auto w-full max-w-4xl rounded-2xl border border-default bg-card p-6 sm:p-8 shadow-lg',
enterClass,
)}
>
{/* Header */}
<div className="flex items-start gap-4">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-warning-dim">
<Sparkles size={18} className="text-warning" />
</span>
<div className="flex-1 min-w-0">
<p className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground">
Escalation handoff
</p>
<h2
id="handoff-context-title"
className="font-heading text-xl sm:text-2xl font-semibold text-heading leading-tight"
>
{problemSummary}
</h2>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
{problemDomain && (
<span className="font-sans rounded-md bg-accent-dim px-1.5 py-0.5 text-[0.5625rem] uppercase tracking-wider text-accent-text">
{problemDomain}
</span>
)}
<span className="flex items-center gap-1">
<Hash size={10} />
{stepCount} {stepCount === 1 ? 'step' : 'steps'}
</span>
{confidenceTier && (
<span className="font-sans uppercase tracking-wider text-[0.5625rem]">
Session confidence: {confidenceTier}
</span>
)}
<span className="flex items-center gap-1">
<Clock size={10} />
Escalated {timeAgo(handoff.created_at)}
</span>
{handoff.priority === 'elevated' && (
<span className="font-sans rounded-md bg-danger-dim px-1.5 py-0.5 text-[0.5625rem] uppercase tracking-wider text-danger border border-danger/20">
Elevated
</span>
)}
</div>
</div>
{dismissible && onDismiss && (
<button
onClick={onDismiss}
aria-label="Close handoff context"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-elevated transition-colors"
>
<X size={16} />
</button>
)}
</div>
{/* Two-column body */}
<div className="mt-6 grid gap-4 md:grid-cols-2">
{/* What's been tried */}
<section
aria-labelledby="handoff-what-tried"
className="card-flat p-4 space-y-3"
>
<div className="flex items-center gap-2">
<FileText size={14} className="text-muted-foreground" />
<h3
id="handoff-what-tried"
className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground"
>
What's been tried
</h3>
</div>
{handoff.engineer_notes ? (
<div>
<p className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground mb-1">
Why they escalated
</p>
<p className="text-sm text-foreground whitespace-pre-wrap">
{handoff.engineer_notes}
</p>
</div>
) : (
<p className="text-sm text-muted-foreground italic">
No notes from the original engineer.
</p>
)}
<div className="rounded-lg bg-elevated px-3 py-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground">{stepCount}</span>{' '}
diagnostic {stepCount === 1 ? 'step' : 'steps'} on record. Full
timeline opens when you start the session.
</div>
</section>
{/* AI assessment */}
<section
aria-labelledby="handoff-ai-assessment"
className="card-flat p-4 space-y-3"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Brain size={14} className="text-muted-foreground" />
<h3
id="handoff-ai-assessment"
className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground"
>
AI assessment
</h3>
</div>
<ConfidenceBadge value={assessmentConfidence} />
</div>
{!assessmentText && !likelyCause && suggestedSteps.length === 0 ? (
<div className="flex items-start gap-2 rounded-lg bg-elevated px-3 py-3 text-xs text-muted-foreground">
<AlertTriangle size={12} className="mt-0.5 shrink-0 text-warning" />
<span>
Assessment unavailable — model didn't respond in time. Pick up
the session to investigate directly.
</span>
</div>
) : (
<>
{likelyCause && (
<div>
<p className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground mb-1">
Likely cause
</p>
<p className="text-sm text-foreground">{likelyCause}</p>
</div>
)}
{assessmentText && !likelyCause && (
<p className="text-sm text-foreground whitespace-pre-wrap">
{assessmentText}
</p>
)}
{suggestedSteps.length > 0 && (
<div>
<p className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground mb-1.5">
Suggested next steps
</p>
<ul className="space-y-1.5">
{suggestedSteps.map((step, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm text-foreground"
>
<Target
size={12}
className="mt-1 shrink-0 text-accent-text"
/>
<span>{step}</span>
</li>
))}
</ul>
</div>
)}
</>
)}
</section>
</div>
{/* Start here CTA */}
{!dismissible && (
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
Picking up assigns this session to you and reactivates it.
</p>
<button
ref={startBtnRef}
onClick={() => void onStartHere()}
disabled={isProcessing}
className="flex items-center justify-center gap-2 rounded-lg bg-accent px-5 py-3 min-h-[44px] text-sm font-semibold text-white hover:brightness-110 active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none transition-all"
>
<ArrowRight size={14} />
{isProcessing ? 'Picking up…' : 'Start here'}
</button>
</div>
)}
</div>
)
}

View File

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