Remove duplicate Update/Close actions from chat toolbars (FlowPilotPage, CockpitPage) — session lifecycle actions now live only in headers. Redesign ViewToggle as a persistent tab bar with bottom-border active indicator and ARIA attributes. Standardize all action naming: Resolve (emerald), Update (blue), Close (rose), Pause (muted). Fix IncidentHeader Resolve from orange to emerald. Delete unused FlowPilotActionBar component (227 lines). Update ConcludeSessionModal copy to use forward-facing action verbs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
518 lines
22 KiB
TypeScript
518 lines
22 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 } 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 { 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 isPickup = searchParams.get('pickup') === 'true'
|
|
const fp = useFlowPilotSession()
|
|
const branching = useBranching()
|
|
const prefillHandledRef = 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
|
|
const [pickingUp, setPickingUp] = useState(false)
|
|
|
|
// Load existing session if ID in URL
|
|
useEffect(() => {
|
|
if (sessionId && !fp.session) {
|
|
fp.loadSession(sessionId)
|
|
}
|
|
}, [sessionId]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// 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-rose-400">{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-amber-500/10">
|
|
<Sparkles size={14} className="text-amber-400" />
|
|
</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-amber-500/10 px-2 py-0.5 text-[0.625rem] uppercase tracking-wider text-amber-400 border border-amber-500/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-amber-500/10">
|
|
<AlertTriangle size={18} className="text-amber-400" />
|
|
</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-gradient-to-r from-blue-500 to-blue-400 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">
|
|
<button
|
|
onClick={() => setShowResolve(true)}
|
|
disabled={!fp.canResolve || fp.isProcessing}
|
|
className="flex items-center gap-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/20 px-3 py-1.5 text-xs font-medium text-emerald-400 hover:bg-emerald-500/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-amber-500/10 border border-amber-500/20 px-3 py-1.5 text-xs font-medium text-amber-400 hover:bg-amber-500/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-blue-500/10 border border-blue-500/20 px-3 py-1.5 text-xs font-medium text-blue-400 hover:bg-blue-500/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
|
title="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-40 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-rose-400 hover:bg-rose-500/10 transition-colors"
|
|
>
|
|
<X size={13} />
|
|
Close
|
|
</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-40 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-emerald-400 hover:bg-emerald-500/10 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-amber-400 hover:bg-amber-500/10 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-blue-400 hover:bg-blue-500/10 transition-colors"
|
|
>
|
|
<FileText size={14} />
|
|
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-rose-400 hover:bg-rose-500/10 transition-colors"
|
|
>
|
|
<X size={14} />
|
|
Close
|
|
</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-emerald-400' :
|
|
fp.session.status === 'escalated' ? 'text-amber-400' :
|
|
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) ── */}
|
|
|
|
{/* 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-emerald-500/20 border border-emerald-500/30 px-4 py-2 min-h-[44px] text-sm font-medium text-emerald-400 hover:bg-emerald-500/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</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-rose-500/20 border border-rose-500/30 px-4 py-2 min-h-[44px] text-sm font-medium text-rose-400 hover:bg-rose-500/30 disabled:opacity-50 transition-colors"
|
|
>
|
|
{submitting ? 'Closing...' : 'Close'}
|
|
</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>
|
|
)
|
|
}
|