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:
308
frontend/src/components/flowpilot/HandoffContextScreen.tsx
Normal file
308
frontend/src/components/flowpilot/HandoffContextScreen.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ export { EscalateModal } from './EscalateModal'
|
|||||||
export { EscalationQueue } from './EscalationQueue'
|
export { EscalationQueue } from './EscalationQueue'
|
||||||
export { EscalationMetricCard } from './EscalationMetricCard'
|
export { EscalationMetricCard } from './EscalationMetricCard'
|
||||||
export { SessionBriefing } from './SessionBriefing'
|
export { SessionBriefing } from './SessionBriefing'
|
||||||
|
export { HandoffContextScreen } from './HandoffContextScreen'
|
||||||
export { ProposalCard } from './ProposalCard'
|
export { ProposalCard } from './ProposalCard'
|
||||||
export { ProposalDetail } from './ProposalDetail'
|
export { ProposalDetail } from './ProposalDetail'
|
||||||
export { InSessionScriptGenerator } from './InSessionScriptGenerator'
|
export { InSessionScriptGenerator } from './InSessionScriptGenerator'
|
||||||
|
|||||||
@@ -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 { Sparkles, Loader2, AlertTriangle, CheckCircle2, ArrowUpRight, FileText, MoreHorizontal, Pause, X } from 'lucide-react'
|
||||||
import { useFlowPilotSession } from '@/hooks/useFlowPilotSession'
|
import { useFlowPilotSession } from '@/hooks/useFlowPilotSession'
|
||||||
import { useBranching } from '@/hooks/useBranching'
|
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 { EscalateModal } from '@/components/flowpilot/EscalateModal'
|
||||||
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
|
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
|
||||||
import { HandoffModal } from '@/components/session/HandoffModal'
|
import { HandoffModal } from '@/components/session/HandoffModal'
|
||||||
@@ -11,6 +11,7 @@ import { handoffsApi } from '@/api/handoffs'
|
|||||||
import { aiSessionsApi } from '@/api'
|
import { aiSessionsApi } from '@/api'
|
||||||
import { integrationsApi } from '@/api/integrations'
|
import { integrationsApi } from '@/api/integrations'
|
||||||
import type { PSATicketInfo } from '@/types/integrations'
|
import type { PSATicketInfo } from '@/types/integrations'
|
||||||
|
import type { HandoffResponse } from '@/types/branching'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
|
|
||||||
export default function FlowPilotSessionPage() {
|
export default function FlowPilotSessionPage() {
|
||||||
@@ -76,12 +77,95 @@ export default function FlowPilotSessionPage() {
|
|||||||
|
|
||||||
const [pickingUp, setPickingUp] = useState(false)
|
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<HandoffResponse | null>(null)
|
||||||
|
const [overlayHandoff, setOverlayHandoff] = useState<HandoffResponse | null>(null)
|
||||||
|
const [overlayLoading, setOverlayLoading] = useState(false)
|
||||||
|
const [claiming, setClaiming] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
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)
|
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
|
// Load branches when session is branching
|
||||||
useEffect(() => {
|
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 (
|
||||||
|
<div className="flex items-center justify-center min-h-[50vh]">
|
||||||
|
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (magicState === 'visible' && magicHandoff) {
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto p-4 sm:p-8">
|
||||||
|
<HandoffContextScreen
|
||||||
|
handoff={magicHandoff}
|
||||||
|
onStartHere={handleStartHere}
|
||||||
|
isProcessing={claiming}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Error state
|
// Error state
|
||||||
if (fp.error && !fp.session) {
|
if (fp.error && !fp.session) {
|
||||||
return (
|
return (
|
||||||
@@ -273,6 +379,17 @@ export default function FlowPilotSessionPage() {
|
|||||||
<>
|
<>
|
||||||
{/* Desktop actions */}
|
{/* Desktop actions */}
|
||||||
<div className="hidden sm:flex items-center gap-1.5">
|
<div className="hidden sm:flex items-center gap-1.5">
|
||||||
|
{magicHandoff && (
|
||||||
|
<button
|
||||||
|
onClick={openHandoffContextOverlay}
|
||||||
|
disabled={overlayLoading}
|
||||||
|
title="Show the handoff context the original engineer sent"
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-border-default px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:border-border-hover disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
<Sparkles size={13} />
|
||||||
|
Context
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowResolve(true)}
|
onClick={() => setShowResolve(true)}
|
||||||
disabled={!fp.canResolve || fp.isProcessing}
|
disabled={!fp.canResolve || fp.isProcessing}
|
||||||
@@ -434,6 +551,23 @@ export default function FlowPilotSessionPage() {
|
|||||||
|
|
||||||
{/* ── Page-level modals (moved from action bar) ── */}
|
{/* ── Page-level modals (moved from action bar) ── */}
|
||||||
|
|
||||||
|
{/* Handoff context overlay — re-opened from the toolbar */}
|
||||||
|
{overlayHandoff && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 backdrop-blur-sm p-4 sm:p-8 animate-fade-in"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) setOverlayHandoff(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HandoffContextScreen
|
||||||
|
handoff={overlayHandoff}
|
||||||
|
onStartHere={() => {}}
|
||||||
|
onDismiss={() => setOverlayHandoff(null)}
|
||||||
|
dismissible
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Resolve modal */}
|
{/* Resolve modal */}
|
||||||
{showResolve && (
|
{showResolve && (
|
||||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
|||||||
Reference in New Issue
Block a user