- Reformat PSA resolution/escalation notes: clean single-line header, steps with engineer responses inline, remove duplicate timing blocks, remove AI confidence section, add follow-up recommendations - Standardize time display to decimal hours (e.g. 0.25 hrs) across all note formatters and status update context - Add follow_up_recommendations to SessionDocumentation schema and surface in SessionDocView; extracted from resolution suggestion steps - Add _build_what_we_know() helper: uses session.evidence_items when cockpit branch merges, falls back to deriving findings from steps - Fix option label lookup in generate_status_update (was passing raw machine values to AI instead of human-readable labels) - Add 'What We Know' section to status update ticket notes prompt - Improve _build_session_context in resolution_output_generator to include intake text and full step details instead of truncated chat - Add request_info audience type: client-facing information request that skips the length step and generates a numbered question list - Improve client_update and email_draft prompts with per-context guidance (status/resolution/escalation) and fix escalation subject line from 'Specialist Review' to 'Specialist Assistance' Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
258 lines
11 KiB
TypeScript
258 lines
11 KiB
TypeScript
import { useState } from 'react'
|
|
import { FileText, User, Mail, HelpCircle, Zap, AlignLeft, Copy, Check, RotateCcw, ArrowLeftRight, Loader2 } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import { toast } from '@/lib/toast'
|
|
import type { StatusUpdateAudience, StatusUpdateLength, StatusUpdateContext, StatusUpdateResponse } from '@/types/ai-session'
|
|
|
|
interface StatusUpdateModalProps {
|
|
open: boolean
|
|
onClose: () => void
|
|
onGenerate: (audience: StatusUpdateAudience, length: StatusUpdateLength, context: StatusUpdateContext) => Promise<StatusUpdateResponse>
|
|
context?: StatusUpdateContext
|
|
hasPsaTicket?: boolean
|
|
}
|
|
|
|
const AUDIENCES: { value: StatusUpdateAudience; icon: typeof FileText; label: string; description: string; skipLength?: boolean }[] = [
|
|
{ value: 'ticket_notes', icon: FileText, label: 'Ticket Notes', description: 'Technical, for your PSA' },
|
|
{ value: 'client_update', icon: User, label: 'Client Update', description: 'Professional, non-technical' },
|
|
{ value: 'email_draft', icon: Mail, label: 'Email Draft', description: 'Full email with subject line' },
|
|
{ value: 'request_info', icon: HelpCircle, label: 'Request Information', description: 'Ask the client specific questions', skipLength: true },
|
|
]
|
|
|
|
const LENGTHS: { value: StatusUpdateLength; icon: typeof Zap; label: string; description: string }[] = [
|
|
{ value: 'quick', icon: Zap, label: 'Quick', description: '1-2 sentences' },
|
|
{ value: 'detailed', icon: AlignLeft, label: 'Detailed', description: 'Full breakdown' },
|
|
]
|
|
|
|
type ModalStep = 'audience' | 'length' | 'generating' | 'result'
|
|
|
|
export function StatusUpdateModal({ open, onClose, onGenerate, context = 'status' }: StatusUpdateModalProps) {
|
|
const [step, setStep] = useState<ModalStep>('audience')
|
|
const [audience, setAudience] = useState<StatusUpdateAudience | null>(null)
|
|
const [length, setLength] = useState<StatusUpdateLength | null>(null)
|
|
const [result, setResult] = useState<StatusUpdateResponse | null>(null)
|
|
const [copied, setCopied] = useState(false)
|
|
|
|
const contextLabels: Record<StatusUpdateContext, string> = {
|
|
status: 'Share Status Update',
|
|
resolution: 'Share Resolution',
|
|
escalation: 'Share Escalation',
|
|
}
|
|
|
|
const handleAudienceSelect = async (value: StatusUpdateAudience) => {
|
|
setAudience(value)
|
|
const opt = AUDIENCES.find(a => a.value === value)
|
|
if (opt?.skipLength) {
|
|
// Skip length selection — always concise for request_info
|
|
setLength('quick')
|
|
setStep('generating')
|
|
try {
|
|
const res = await onGenerate(value, 'quick', context)
|
|
setResult(res)
|
|
setStep('result')
|
|
} catch {
|
|
setStep('audience')
|
|
setAudience(null)
|
|
}
|
|
} else {
|
|
setStep('length')
|
|
}
|
|
}
|
|
|
|
const handleLengthSelect = async (value: StatusUpdateLength) => {
|
|
if (!audience) return
|
|
setLength(value)
|
|
setStep('generating')
|
|
try {
|
|
const res = await onGenerate(audience, value, context)
|
|
setResult(res)
|
|
setStep('result')
|
|
} catch {
|
|
setStep('audience')
|
|
setAudience(null)
|
|
setLength(null)
|
|
}
|
|
}
|
|
|
|
const handleCopy = async () => {
|
|
if (!result) return
|
|
await navigator.clipboard.writeText(result.content)
|
|
setCopied(true)
|
|
toast.success('Copied to clipboard')
|
|
setTimeout(() => setCopied(false), 2000)
|
|
}
|
|
|
|
const handleRegenerate = async () => {
|
|
if (!audience || !length) return
|
|
setStep('generating')
|
|
try {
|
|
const res = await onGenerate(audience, length, context)
|
|
setResult(res)
|
|
setStep('result')
|
|
} catch {
|
|
setStep('result')
|
|
}
|
|
}
|
|
|
|
const handleSwitchAudience = () => {
|
|
setStep('audience')
|
|
setAudience(null)
|
|
setLength(null)
|
|
setResult(null)
|
|
setCopied(false)
|
|
}
|
|
|
|
const handleClose = () => {
|
|
setStep('audience')
|
|
setAudience(null)
|
|
setLength(null)
|
|
setResult(null)
|
|
setCopied(false)
|
|
onClose()
|
|
}
|
|
|
|
if (!open) return null
|
|
|
|
return (
|
|
<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 rounded-t-2xl sm:rounded-2xl overflow-hidden">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-4 sm:px-6 pt-4 sm:pt-6 pb-3" style={{ borderBottom: '1px solid var(--color-border-default)' }}>
|
|
<h3 className="font-heading text-lg font-semibold text-foreground">
|
|
{contextLabels[context]}
|
|
</h3>
|
|
<button onClick={handleClose} className="text-muted-foreground hover:text-foreground text-sm">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
|
|
<div className="px-4 sm:px-6 py-4 sm:py-5">
|
|
{/* Step 1: Audience */}
|
|
{step === 'audience' && (
|
|
<div className="space-y-3">
|
|
<p className="text-sm text-muted-foreground mb-4">Who is this update for?</p>
|
|
{AUDIENCES.map((opt) => {
|
|
const Icon = opt.icon
|
|
return (
|
|
<button
|
|
key={opt.value}
|
|
onClick={() => handleAudienceSelect(opt.value)}
|
|
className="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left transition-colors hover:bg-[var(--color-bg-elevated)]"
|
|
style={{ border: '1px solid var(--color-border-default)' }}
|
|
>
|
|
<Icon size={18} className="text-muted-foreground shrink-0" />
|
|
<div>
|
|
<p className="text-sm font-medium text-foreground">{opt.label}</p>
|
|
<p className="text-xs text-muted-foreground">{opt.description}</p>
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Step 2: Length */}
|
|
{step === 'length' && (
|
|
<div className="space-y-3">
|
|
<p className="text-sm text-muted-foreground mb-4">How detailed?</p>
|
|
{LENGTHS.map((opt) => {
|
|
const Icon = opt.icon
|
|
return (
|
|
<button
|
|
key={opt.value}
|
|
onClick={() => handleLengthSelect(opt.value)}
|
|
className="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left transition-colors hover:bg-[var(--color-bg-elevated)]"
|
|
style={{ border: '1px solid var(--color-border-default)' }}
|
|
>
|
|
<Icon size={18} className="text-muted-foreground shrink-0" />
|
|
<div>
|
|
<p className="text-sm font-medium text-foreground">{opt.label}</p>
|
|
<p className="text-xs text-muted-foreground">{opt.description}</p>
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
<button
|
|
onClick={() => { setStep('audience'); setAudience(null) }}
|
|
className="text-xs text-muted-foreground hover:text-foreground mt-2"
|
|
>
|
|
← Back
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Step 3: Generating */}
|
|
{step === 'generating' && (
|
|
<div className="flex flex-col items-center justify-center py-8 gap-3">
|
|
<Loader2 size={24} className="animate-spin text-blue-400" />
|
|
<p className="text-sm text-muted-foreground">
|
|
{audience === 'request_info' ? 'Drafting information request...' : audience === 'email_draft' ? 'Generating email draft...' : audience === 'client_update' ? 'Generating client update...' : 'Generating ticket notes...'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Step 4: Result */}
|
|
{step === 'result' && result && (
|
|
<div className="space-y-4">
|
|
{/* Meta badges */}
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium bg-blue-500/10 text-blue-400 border border-blue-500/20">
|
|
{AUDIENCES.find(a => a.value === result.audience)?.label}
|
|
</span>
|
|
<span className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium bg-[rgba(255,255,255,0.06)] text-muted-foreground border border-[rgba(255,255,255,0.08)]">
|
|
{result.length === 'quick' ? 'Quick' : 'Detailed'}
|
|
</span>
|
|
{result.time_spent_display && (
|
|
<span className="text-xs text-muted-foreground">
|
|
{result.steps_completed} steps · {result.time_spent_display}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Generated content */}
|
|
<div
|
|
className="max-h-64 overflow-y-auto rounded-lg p-4 text-sm text-foreground whitespace-pre-wrap font-mono leading-relaxed"
|
|
style={{ background: 'var(--color-bg-page)', border: '1px solid var(--color-border-default)' }}
|
|
>
|
|
{result.content}
|
|
</div>
|
|
|
|
{/* Action buttons */}
|
|
<div className="flex flex-wrap gap-2">
|
|
<button
|
|
onClick={handleCopy}
|
|
className={cn(
|
|
'flex items-center gap-2 rounded-lg px-4 py-2 min-h-[44px] text-sm font-medium transition-colors',
|
|
copied
|
|
? 'bg-emerald-500/20 border border-emerald-500/30 text-emerald-400'
|
|
: 'bg-blue-500/10 border border-blue-500/20 text-blue-400 hover:bg-blue-500/20'
|
|
)}
|
|
>
|
|
{copied ? <Check size={16} /> : <Copy size={16} />}
|
|
{copied ? 'Copied!' : 'Copy'}
|
|
</button>
|
|
|
|
<button
|
|
onClick={handleRegenerate}
|
|
className="flex items-center gap-2 rounded-lg bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 min-h-[44px] text-sm font-medium text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.12)] transition-colors"
|
|
>
|
|
<RotateCcw size={16} />
|
|
Regenerate
|
|
</button>
|
|
|
|
<button
|
|
onClick={handleSwitchAudience}
|
|
className="flex items-center gap-2 rounded-lg bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 min-h-[44px] text-sm font-medium text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.12)] transition-colors"
|
|
>
|
|
<ArrowLeftRight size={16} />
|
|
Switch
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|