Files
resolutionflow/frontend/src/pages/FlowPilotSessionPage.tsx
Michael Chihlas 8e9d22e0e0 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>
2026-04-27 21:06:14 -04:00

682 lines
29 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import { useParams, useSearchParams, useLocation, useBlocker, useNavigate } from 'react-router-dom'
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, HandoffContextScreen } from '@/components/flowpilot'
import { EscalateModal } from '@/components/flowpilot/EscalateModal'
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
import { HandoffModal } from '@/components/session/HandoffModal'
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() {
const { sessionId } = useParams<{ sessionId?: string }>()
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
const location = useLocation()
const prefill = (location.state as { prefill?: string } | null)?.prefill || ''
const locationState = location.state as { psaTicketId?: string; psaTicket?: PSATicketInfo } | null
const psaTicketId = locationState?.psaTicketId
const psaTicket = locationState?.psaTicket
const isPickup = searchParams.get('pickup') === 'true'
const fp = useFlowPilotSession()
const branching = useBranching()
const prefillHandledRef = useRef(false)
const psaTicketHandledRef = useRef(false)
const [showOverflow, setShowOverflow] = useState(false)
const [showResolve, setShowResolve] = useState(false)
const [showEscalate, setShowEscalate] = useState(false)
const [showAbandon, setShowAbandon] = useState(false)
const [showStatusUpdate, setShowStatusUpdate] = useState(false)
const [showHandoff, setShowHandoff] = useState(false)
const [resolutionSummary, setResolutionSummary] = useState('')
const [submitting, setSubmitting] = useState(false)
// Block navigation when session is active
const isActiveSession = fp.session?.status === 'active'
const blocker = useBlocker(
({ currentLocation, nextLocation }) =>
!!isActiveSession && currentLocation.pathname !== nextLocation.pathname
)
// Auto-submit when navigating from dashboard with prefilled problem
useEffect(() => {
if (prefill && !prefillHandledRef.current && !sessionId && !fp.session && !fp.isLoading) {
prefillHandledRef.current = true
fp.startSession({ intake_type: 'free_text', intake_content: { text: prefill } })
}
}, [prefill, sessionId, fp.session, fp.isLoading]) // eslint-disable-line react-hooks/exhaustive-deps
// Auto-start when navigating from TicketQueue with a PSA ticket
useEffect(() => {
if (psaTicketId && psaTicket && !psaTicketHandledRef.current && !sessionId && !fp.session && !fp.isLoading) {
psaTicketHandledRef.current = true
integrationsApi.getConnection().then((conn) => {
if (conn?.id) {
fp.startSession({
intake_type: 'psa_ticket',
intake_content: {
ticket_data: {
summary: psaTicket.summary,
company: psaTicket.company_name,
priority: psaTicket.priority_name,
},
},
psa_ticket_id: psaTicketId,
psa_connection_id: conn.id,
})
}
})
}
}, [psaTicketId, psaTicket, sessionId, fp.session, fp.isLoading]) // eslint-disable-line react-hooks/exhaustive-deps
const [pickingUp, setPickingUp] = useState(false)
// ── 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(() => {
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, 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(() => {
if (fp.session?.is_branching && fp.session.id) {
branching.loadBranches(fp.session.id)
}
}, [fp.session?.is_branching, fp.session?.id]) // eslint-disable-line react-hooks/exhaustive-deps
const handlePickupContinue = async () => {
if (!sessionId) return
setPickingUp(true)
try {
await aiSessionsApi.pickupSession(sessionId, { resume_mode: 'continue' })
// Clear pickup param and reload the session as active
setSearchParams({})
await fp.loadSession(sessionId)
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Failed to pick up session'
toast.error(message)
} finally {
setPickingUp(false)
}
}
const handleBranchSwitch = async (branchId: string) => {
if (!fp.session) return
const result = await branching.switchBranch(fp.session.id, branchId)
if (result) {
// Reload session to get updated steps for the switched branch
await fp.loadSession(fp.session.id)
}
}
const handlePickupFresh = async (context: string) => {
if (!sessionId) return
setPickingUp(true)
try {
await aiSessionsApi.pickupSession(sessionId, {
resume_mode: 'fresh',
additional_context: context,
})
setSearchParams({})
await fp.loadSession(sessionId)
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Failed to pick up session'
toast.error(message)
} finally {
setPickingUp(false)
}
}
// 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
if (fp.error && !fp.session) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<div className="card-flat p-6 text-center max-w-md">
<p className="text-sm text-danger">{fp.error}</p>
<button
onClick={() => window.location.reload()}
className="mt-3 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Try again
</button>
</div>
</div>
)
}
// Loading
if (fp.isLoading && !fp.session) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
</div>
)
}
// Intake screen (no session yet)
if (!fp.session) {
return (
<div className="h-full p-6">
<FlowPilotIntake onSubmit={fp.startSession} isLoading={fp.isLoading} defaultProblem={prefill} />
</div>
)
}
// Escalation pickup briefing
if (isPickup && fp.session.status === 'requesting_escalation' && fp.session.escalation_reason) {
// Build escalation package from session detail
// The escalation_package is in the session but not directly on AISessionDetail —
// we use what's available from the session fields
const escalationPackage = {
problem_summary: fp.session.problem_summary ?? undefined,
escalation_reason: fp.session.escalation_reason ?? undefined,
// Steps are available from the session detail
steps_tried: fp.allSteps.map(step => ({
step_type: step.step_type,
description: (step.content as Record<string, string>)?.text || '',
})),
}
return (
<div className="flex h-full flex-col">
{/* Header */}
<div
className="flex items-center gap-3 border-b px-5 py-3 shrink-0"
style={{ borderColor: 'var(--color-border-default)' }}
>
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-warning-dim">
<Sparkles size={14} className="text-warning" />
</span>
<div className="flex-1 min-w-0">
<h1 className="font-heading text-sm font-semibold text-foreground truncate">
Escalation Pickup {fp.session.problem_summary || 'FlowPilot Session'}
</h1>
</div>
<span className="font-sans text-xs rounded-md bg-warning-dim px-2 py-0.5 text-[0.625rem] uppercase tracking-wider text-warning border border-warning/20">
Awaiting pickup
</span>
</div>
{/* Briefing */}
<div className="flex-1 overflow-y-auto p-6">
<div className="mx-auto max-w-2xl">
<SessionBriefing
escalationPackage={escalationPackage}
onContinue={handlePickupContinue}
onFresh={handlePickupFresh}
isProcessing={pickingUp}
/>
</div>
</div>
</div>
)
}
// Active/completed session
return (
<div className="flex h-full flex-col">
{/* Navigation guard modal */}
{blocker.state === 'blocked' && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
<div className="bg-card border border-border rounded-xl w-full max-w-md p-6 shadow-lg">
<div className="flex items-center gap-3 mb-3">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-warning-dim">
<AlertTriangle size={18} className="text-warning" />
</span>
<h2 className="text-lg font-heading font-semibold text-foreground">Active Session</h2>
</div>
<p className="mb-4 text-sm text-muted-foreground">
You have an active troubleshooting session. If you leave, your session will be paused and you can resume it later.
</p>
<div className="flex gap-2">
<button
onClick={() => blocker.reset()}
className="flex-1 rounded-lg bg-accent px-4 py-2.5 text-sm font-semibold text-white hover:brightness-110 active:scale-[0.98] transition-all"
>
Stay in Session
</button>
<button
onClick={() => {
fp.pauseSession()
blocker.proceed()
}}
className="flex-1 rounded-lg bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2.5 text-sm font-medium text-foreground hover:border-[rgba(255,255,255,0.12)] transition-all"
>
Pause & Leave
</button>
</div>
</div>
</div>
)}
{/* Header with actions */}
<div
className="flex items-center gap-3 border-b border-border px-3 sm:px-5 py-2.5 shrink-0"
>
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent-dim shrink-0">
<Sparkles size={14} className="text-primary" />
</span>
<div className="flex-1 min-w-0">
<h1 className="font-heading text-sm font-semibold text-foreground truncate">
{fp.session.problem_summary || 'FlowPilot Session'}
</h1>
</div>
{/* Action buttons — desktop inline, mobile overflow menu */}
{fp.session.status === 'active' && (
<>
{/* Desktop actions */}
<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
onClick={() => setShowResolve(true)}
disabled={!fp.canResolve || fp.isProcessing}
className="flex items-center gap-1.5 rounded-lg bg-success-dim border border-success/20 px-3 py-1.5 text-xs font-medium text-success hover:bg-success/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
>
<CheckCircle2 size={13} />
Resolve
</button>
<button
onClick={() => setShowEscalate(true)}
disabled={!fp.canEscalate || fp.isProcessing}
className="flex items-center gap-1.5 rounded-lg bg-warning-dim border border-warning/20 px-3 py-1.5 text-xs font-medium text-warning hover:bg-warning/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
>
<ArrowUpRight size={13} />
Escalate
</button>
{fp.allSteps.length >= 2 && (
<button
onClick={() => setShowStatusUpdate(true)}
disabled={fp.isProcessing}
className="flex items-center gap-1.5 rounded-lg bg-accent-dim border border-accent/20 px-3 py-1.5 text-xs font-medium text-accent hover:bg-accent/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
title="Share Update"
>
<FileText size={13} />
Update
</button>
)}
{/* Overflow: Pause / Close */}
<div className="relative">
<button
onClick={() => setShowOverflow(!showOverflow)}
className="flex items-center justify-center rounded-lg px-2 py-1.5 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<MoreHorizontal size={16} />
</button>
{showOverflow && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowOverflow(false)} />
<div className="absolute right-0 top-full mt-1 z-50 w-36 rounded-lg border border-border bg-card py-1 shadow-lg">
<button
onClick={() => { setShowOverflow(false); fp.pauseSession() }}
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<Pause size={13} />
Pause
</button>
<button
onClick={() => { setShowOverflow(false); setShowAbandon(true) }}
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-danger hover:bg-danger-dim transition-colors"
>
<X size={13} />
Close Session
</button>
</div>
</>
)}
</div>
</div>
{/* Mobile: single overflow menu */}
<div className="sm:hidden relative">
<button
onClick={() => setShowOverflow(!showOverflow)}
className="flex items-center justify-center rounded-lg px-2 py-1.5 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<MoreHorizontal size={18} />
</button>
{showOverflow && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowOverflow(false)} />
<div className="absolute right-0 top-full mt-1 z-50 w-44 rounded-lg border border-border bg-card py-1 shadow-lg">
<button
onClick={() => { setShowOverflow(false); setShowResolve(true) }}
disabled={!fp.canResolve}
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-success hover:bg-success-dim transition-colors disabled:opacity-40"
>
<CheckCircle2 size={14} />
Resolve
</button>
<button
onClick={() => { setShowOverflow(false); setShowEscalate(true) }}
disabled={!fp.canEscalate}
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-warning hover:bg-warning-dim transition-colors disabled:opacity-40"
>
<ArrowUpRight size={14} />
Escalate
</button>
{fp.allSteps.length >= 2 && (
<button
onClick={() => { setShowOverflow(false); setShowStatusUpdate(true) }}
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-accent hover:bg-accent-dim transition-colors"
>
<FileText size={14} />
Share Update
</button>
)}
<div className="my-1 border-t border-border" />
<button
onClick={() => { setShowOverflow(false); fp.pauseSession() }}
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<Pause size={14} />
Pause
</button>
<button
onClick={() => { setShowOverflow(false); setShowAbandon(true) }}
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-muted-foreground hover:text-danger hover:bg-danger-dim transition-colors"
>
<X size={14} />
Close Session
</button>
</div>
</>
)}
</div>
</>
)}
{/* Status badge — non-active states */}
{fp.session.status !== 'active' && (
<span className={`flex items-center gap-1.5 text-[0.625rem] uppercase tracking-wider font-sans ${
fp.session.status === 'resolved' ? 'text-success' :
fp.session.status === 'escalated' ? 'text-warning' :
fp.session.status === 'paused' ? 'text-muted-foreground' :
'text-muted-foreground'
}`}>
<span className={`w-1.5 h-1.5 rounded-full ${
fp.session.status === 'resolved' ? 'bg-emerald-400' :
fp.session.status === 'escalated' ? 'bg-amber-400' :
fp.session.status === 'paused' ? 'bg-muted-foreground' :
'bg-muted-foreground'
}`} />
{fp.session.status}
</span>
)}
</div>
{/* Session content */}
<div className="flex-1 min-h-0 flex flex-col">
<FlowPilotSession
session={fp.session}
allSteps={fp.allSteps}
currentStep={fp.currentStep}
isProcessing={fp.isProcessing}
documentation={fp.documentation}
psaPushStatus={fp.psaPushStatus}
psaPushError={fp.psaPushError}
memberMappingWarning={fp.memberMappingWarning}
onRespond={fp.respondToStep}
onResume={fp.resumeOwnSession}
onRate={fp.rateSession}
onReloadSession={() => fp.loadSession(fp.session!.id)}
onGenerateStatusUpdate={(audience, length, context) => fp.generateStatusUpdate({ audience, length, context })}
branches={branching.branches}
activeBranchId={branching.activeBranchId}
onBranchSwitch={handleBranchSwitch}
/>
</div>
{/* ── 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 */}
{showResolve && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="card-flat w-full max-w-full sm:max-w-lg mx-0 sm:mx-4 p-4 sm:p-6 rounded-t-2xl sm:rounded-2xl">
<h3 className="font-heading text-lg font-semibold text-foreground mb-1">Resolve Session</h3>
<p className="text-sm text-muted-foreground mb-4">Summarize what fixed the issue. This will be included in the auto-generated documentation.</p>
<textarea
value={resolutionSummary}
onChange={(e) => setResolutionSummary(e.target.value)}
placeholder="What resolved the issue?"
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none resize-none"
rows={4}
autoFocus
/>
<div className="mt-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button
onClick={() => setShowResolve(false)}
className="rounded-lg px-4 py-2 min-h-[44px] text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
<button
onClick={async () => {
if (resolutionSummary.length < 5) return
setSubmitting(true)
try {
await fp.resolveSession({ resolution_summary: resolutionSummary })
setShowResolve(false)
} finally {
setSubmitting(false)
}
}}
disabled={resolutionSummary.length < 5 || submitting}
className="rounded-lg bg-success/20 border border-success/30 px-4 py-2 min-h-[44px] text-sm font-medium text-success hover:bg-success/30 disabled:opacity-50 transition-colors"
>
{submitting ? 'Resolving...' : 'Resolve Session'}
</button>
</div>
</div>
</div>
)}
{/* Close/Abandon confirmation */}
{showAbandon && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="card-flat w-full max-w-full sm:max-w-lg mx-0 sm:mx-4 p-4 sm:p-6 rounded-t-2xl sm:rounded-2xl">
<h3 className="font-heading text-lg font-semibold text-foreground mb-1">Close Session</h3>
<p className="text-sm text-muted-foreground mb-4">
Are you sure you want to close this session? The session history will be kept but it won't count as resolved.
</p>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button
onClick={() => setShowAbandon(false)}
className="rounded-lg px-4 py-2 min-h-[44px] text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
<button
onClick={async () => {
setSubmitting(true)
try {
await fp.abandonSession()
setShowAbandon(false)
navigate('/sessions')
} finally {
setSubmitting(false)
}
}}
disabled={submitting}
className="rounded-lg bg-danger/20 border border-danger/30 px-4 py-2 min-h-[44px] text-sm font-medium text-danger hover:bg-danger/30 disabled:opacity-50 transition-colors"
>
{submitting ? 'Closing...' : 'Close Session'}
</button>
</div>
</div>
</div>
)}
{/* Escalate modal */}
<EscalateModal
open={showEscalate}
onClose={() => setShowEscalate(false)}
onEscalate={fp.escalateSession}
isProcessing={fp.isProcessing || submitting}
hasPsaTicket={!!fp.session.psa_ticket_id}
sessionId={fp.session.id}
/>
{/* Status Update modal */}
<StatusUpdateModal
open={showStatusUpdate}
onClose={() => setShowStatusUpdate(false)}
onGenerate={(audience, length, context) => fp.generateStatusUpdate({ audience, length, context })}
context="status"
hasPsaTicket={!!fp.session.psa_ticket_id}
/>
{/* Handoff modal (branching sessions) */}
{fp.session.is_branching && showHandoff && (
<HandoffModal
onClose={() => setShowHandoff(false)}
onSubmit={async (data) => {
await handoffsApi.createHandoff(fp.session!.id, data)
setShowHandoff(false)
toast.success('Handoff created')
}}
/>
)}
</div>
)
}