feat: add HandoffModal for unified park/escalate
Fixed overlay with park/escalate intent toggle (accent-dim when active), notes textarea, elevated priority checkbox (escalate only), and Cancel/Submit buttons. Mobile responsive: items-end on mobile, items-center on sm+. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
167
frontend/src/components/session/HandoffModal.tsx
Normal file
167
frontend/src/components/session/HandoffModal.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { HandoffCreateRequest } from '@/types/branching'
|
||||
|
||||
interface HandoffModalProps {
|
||||
onClose: () => void
|
||||
onSubmit: (data: HandoffCreateRequest) => Promise<void>
|
||||
}
|
||||
|
||||
type HandoffIntent = 'park' | 'escalate'
|
||||
|
||||
export function HandoffModal({ onClose, onSubmit }: HandoffModalProps) {
|
||||
const [intent, setIntent] = useState<HandoffIntent>('park')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [elevated, setElevated] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit() {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const data: HandoffCreateRequest = {
|
||||
intent,
|
||||
engineer_notes: notes.trim() || undefined,
|
||||
priority: intent === 'escalate' && elevated ? 'elevated' : 'normal',
|
||||
}
|
||||
await onSubmit(data)
|
||||
onClose()
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 w-full max-w-full sm:max-w-lg',
|
||||
'bg-card border border-default rounded-lg',
|
||||
'flex flex-col gap-0'
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Hand off session"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-default">
|
||||
<h2 className="text-sm font-semibold text-heading">Hand Off Session</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted hover:text-primary transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex flex-col gap-4 px-4 py-4">
|
||||
{/* Intent toggle */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted">
|
||||
Handoff Type
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIntent('park')}
|
||||
className={cn(
|
||||
'flex-1 rounded-[5px] border px-3 py-2 text-sm font-medium transition-colors',
|
||||
intent === 'park'
|
||||
? 'border-accent bg-accent-dim text-accent-text'
|
||||
: 'border-default bg-transparent text-secondary hover:bg-elevated hover:text-primary'
|
||||
)}
|
||||
>
|
||||
Park
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIntent('escalate')}
|
||||
className={cn(
|
||||
'flex-1 rounded-[5px] border px-3 py-2 text-sm font-medium transition-colors',
|
||||
intent === 'escalate'
|
||||
? 'border-accent bg-accent-dim text-accent-text'
|
||||
: 'border-default bg-transparent text-secondary hover:bg-elevated hover:text-primary'
|
||||
)}
|
||||
>
|
||||
Escalate
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-secondary">
|
||||
{intent === 'park'
|
||||
? 'Park this session to resume later or hand to another engineer.'
|
||||
: 'Escalate to a senior engineer with full context and branch history.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="handoff-notes"
|
||||
className="text-[10px] font-semibold uppercase tracking-wider text-muted"
|
||||
>
|
||||
Engineer Notes
|
||||
<span className="ml-1 normal-case font-normal text-muted">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="handoff-notes"
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Add context for whoever picks this up…"
|
||||
className={cn(
|
||||
'w-full resize-none rounded-[5px] border border-default bg-input',
|
||||
'px-3 py-2 text-sm text-primary placeholder:text-muted',
|
||||
'focus:outline-none focus:border-accent focus:shadow-[0_0_0_2px_var(--color-accent-dim)]',
|
||||
'transition-colors'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Priority (escalate only) */}
|
||||
{intent === 'escalate' && (
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={elevated}
|
||||
onChange={e => setElevated(e.target.checked)}
|
||||
className="accent-accent w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm text-primary">Mark as elevated priority</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-default">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="rounded-[5px] border border-default px-4 py-2 text-sm text-secondary hover:bg-elevated hover:text-primary transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
className="rounded-[5px] bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-[#ea580c] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'Submitting…' : intent === 'park' ? 'Park Session' : 'Escalate Session'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user