Codex review pass on the escalation wedge. Reworks claim_session from read-then-write to a conditional UPDATE so two seniors racing can't both win, blocks the original engineer from claiming their own handoff, and filters self-escalated sessions out of the dashboard escalation queue. Also preassigns the handoff UUID before flush so the compatibility escalation_package payload carries it. Removes legacy frontend pickup state (claiming, handleStartHere) that broke tsc --noEmit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
411 lines
16 KiB
TypeScript
411 lines
16 KiB
TypeScript
import { useEffect, useMemo, useRef } from 'react'
|
|
import {
|
|
AlertTriangle,
|
|
ArrowRight,
|
|
Brain,
|
|
Clock,
|
|
FileText,
|
|
Hash,
|
|
Loader2,
|
|
Sparkles,
|
|
Target,
|
|
User,
|
|
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
|
|
// Pre-claim entry point: one of three choices is made before claiming.
|
|
// Post-claim re-open (dismissible=true) keeps the legacy onStartHere path.
|
|
onContinue?: () => Promise<void> | void
|
|
onAIAnalysis?: () => Promise<void> | void
|
|
onOwnThing?: () => Promise<void> | void
|
|
// Legacy single-CTA — used when dismissible=true (post-claim toolbar re-open)
|
|
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
|
|
// Whether the task lane has items — drives the 3-option vs 2-option layout
|
|
hasTaskLane?: boolean
|
|
activeOptionKey?: 'continue' | 'ai' | 'own' | null
|
|
}
|
|
|
|
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,
|
|
onContinue,
|
|
onAIAnalysis,
|
|
onOwnThing,
|
|
onDismiss,
|
|
dismissible = false,
|
|
isProcessing = false,
|
|
hasTaskLane = false,
|
|
activeOptionKey = null,
|
|
}: 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 whatWeKnow = assessment?.what_we_know ?? []
|
|
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>
|
|
AI assessment is still generating. Reopen this view in a few
|
|
seconds to see it, or 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>
|
|
)}
|
|
{whatWeKnow.length > 0 && (
|
|
<div>
|
|
<p className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground mb-1.5">
|
|
What we know
|
|
</p>
|
|
<ul className="space-y-1">
|
|
{whatWeKnow.map((fact, i) => (
|
|
<li key={i} className="text-sm text-foreground flex items-start gap-2">
|
|
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/50" />
|
|
<span>{fact}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</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>
|
|
|
|
{/* CTA footer */}
|
|
{dismissible ? (
|
|
// Post-claim re-open from toolbar — single close action
|
|
<div className="mt-6 flex justify-end">
|
|
<button
|
|
onClick={() => onDismiss?.()}
|
|
className="px-4 py-2 rounded-lg text-sm text-muted-foreground hover:text-foreground bg-input border border-border hover:border-border-hover transition-all"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
) : (
|
|
// Pre-claim: 3 options (task lane exists) or 2 options (empty lane)
|
|
<div className="mt-6 space-y-2">
|
|
<p className="text-xs text-muted-foreground mb-3">
|
|
How would you like to approach this session?
|
|
</p>
|
|
|
|
{/* Continue — only when task lane has items */}
|
|
{hasTaskLane && onContinue && (
|
|
<button
|
|
ref={startBtnRef}
|
|
onClick={() => void onContinue()}
|
|
disabled={isProcessing}
|
|
className={cn(
|
|
'w-full flex items-center gap-3 rounded-lg px-4 py-3 min-h-[52px] text-sm font-semibold transition-all',
|
|
'bg-accent text-white hover:brightness-110 active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none',
|
|
)}
|
|
>
|
|
{activeOptionKey === 'continue' ? (
|
|
<Loader2 size={16} className="shrink-0 animate-spin" />
|
|
) : (
|
|
<ArrowRight size={16} className="shrink-0" />
|
|
)}
|
|
<span className="flex-1 text-left">
|
|
Continue where{' '}
|
|
<span className="font-bold">
|
|
{handoff.handed_off_by_name ?? 'the original engineer'}
|
|
</span>{' '}
|
|
left off
|
|
</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* AI analysis */}
|
|
{onAIAnalysis && (
|
|
<button
|
|
ref={!hasTaskLane ? startBtnRef : undefined}
|
|
onClick={() => void onAIAnalysis()}
|
|
disabled={isProcessing}
|
|
className={cn(
|
|
'w-full flex items-center gap-3 rounded-lg border px-4 py-3 min-h-[52px] text-sm font-semibold transition-all disabled:opacity-50 disabled:pointer-events-none',
|
|
hasTaskLane
|
|
? 'border-border bg-card text-foreground hover:bg-elevated hover:border-border-hover active:scale-[0.98]'
|
|
: 'bg-accent text-white border-transparent hover:brightness-110 active:scale-[0.98]',
|
|
)}
|
|
>
|
|
{activeOptionKey === 'ai' ? (
|
|
<Loader2 size={16} className="shrink-0 animate-spin" />
|
|
) : (
|
|
<Sparkles size={16} className="shrink-0" />
|
|
)}
|
|
<span className="flex-1 text-left">Get AI analysis</span>
|
|
<span className="text-xs font-normal opacity-70">
|
|
{hasTaskLane ? 'Fresh take on what\'s been tried' : 'Generate diagnostic steps'}
|
|
</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Own approach */}
|
|
{onOwnThing && (
|
|
<button
|
|
onClick={() => void onOwnThing()}
|
|
disabled={isProcessing}
|
|
className="w-full flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 min-h-[52px] text-sm text-foreground hover:bg-elevated hover:border-border-hover active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none transition-all"
|
|
>
|
|
{activeOptionKey === 'own' ? (
|
|
<Loader2 size={16} className="shrink-0 animate-spin text-muted-foreground" />
|
|
) : (
|
|
<User size={16} className="shrink-0 text-muted-foreground" />
|
|
)}
|
|
<span className="flex-1 text-left">I'll take it from here</span>
|
|
<span className="text-xs text-muted-foreground">I know what to try</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|