Files
resolutionflow/frontend/src/components/common/RichTextInput.tsx
Michael Chihlas 0d1b305619 fix(escalations): live-test fixes from QA bash
Bundles four fixes from the live debugging session:

1. AssistantChatPage: replace urlSessionId === activeChatId gate with a
   loadedChatIdsRef. After 8914391 made activeChatId initialize from
   urlSessionId, the gate short-circuited fresh mounts and selectChat
   never fired. Symptom: senior picks up an escalation, lands on a blank
   chat surface with no conversation history and no sidebar entry. Fix
   also adds loadChats() in handleStartHere so the picked-up session
   appears in the sidebar (its escalated_to_id is null pre-claim, so
   listSessions doesn't return it until claim_session sets it).

2. config: bump ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS 15s → 45s.
   Sonnet was hitting tail latency at 15s in the field, leaving the
   magic-moment placeholder permanent. Background-task architecture
   (e8ba74e) means this no longer blocks the user; it's just the budget
   before publishing has_assessment=false. NOTE: live test still shows
   assessment not populating — see HANDOFF for the consolidation plan
   that supersedes this.

3. Enter-to-submit: chat-input convention (Enter submits, Shift+Enter
   inserts newline) on the escalate-flow forms. RichTextInput gains an
   optional onSubmit prop; EscalateModal wires it to handleSubmit;
   ConcludeSessionModal gets the same handler on its plain textarea.

4. PendingEscalations: each row is now expandable. Click row body to
   reveal the engineer's escalation reason, step count on record,
   confidence tier, and PSA ticket number. Pick Up still clicks through
   directly. Single-expand-at-a-time keeps the dashboard compact.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 00:18:40 -04:00

304 lines
9.4 KiB
TypeScript

import { useState, useRef, useEffect, useCallback } from 'react'
import { Loader2, X, RotateCcw, ImagePlus } from 'lucide-react'
import { cn } from '@/lib/utils'
import { uploadsApi } from '@/api/uploads'
import type { FileUploadResponse, PendingUpload } from '@/types/upload'
interface RichTextInputProps {
value: string
onChange: (value: string) => void
onFilesChange?: (uploads: FileUploadResponse[]) => void
sessionId?: string
placeholder?: string
rows?: number
className?: string
disabled?: boolean
// Enter-to-submit, matching the chat-input convention used elsewhere in
// the app: plain Enter calls onSubmit; Shift+Enter inserts a newline.
// Parents that want the legacy "Enter = newline only" behavior just
// omit this prop.
onSubmit?: () => void
}
export function RichTextInput({
value,
onChange,
onFilesChange,
sessionId,
placeholder,
rows = 3,
className,
disabled,
onSubmit,
}: RichTextInputProps) {
const [pendingUploads, setPendingUploads] = useState<PendingUpload[]>([])
const [isDragOver, setIsDragOver] = useState(false)
const [isFocused, setIsFocused] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const dragCounterRef = useRef(0)
// Cleanup blob URLs on unmount
useEffect(() => {
return () => {
pendingUploads.forEach((upload) => {
URL.revokeObjectURL(upload.preview)
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const processFiles = useCallback(
(files: File[]) => {
const imageFiles = files.filter((f) => f.type.startsWith('image/'))
if (imageFiles.length === 0) return
const newUploads: PendingUpload[] = imageFiles.map((file) => ({
id: crypto.randomUUID(),
file,
preview: URL.createObjectURL(file),
status: 'uploading' as const,
}))
setPendingUploads((prev) => [...prev, ...newUploads])
// Upload each file
newUploads.forEach((upload) => {
uploadsApi
.upload(upload.file, sessionId)
.then((result) => {
setPendingUploads((prev) => {
const updated = prev.map((u) =>
u.id === upload.id ? { ...u, status: 'done' as const, result } : u
)
// Notify parent of all completed uploads
const completed = updated
.filter((u) => u.status === 'done' && u.result)
.map((u) => u.result!)
onFilesChange?.(completed)
return updated
})
})
.catch((err) => {
const errorMsg = err?.response?.status === 503
? 'File uploads not available — contact your administrator'
: err?.message || 'Upload failed'
setPendingUploads((prev) =>
prev.map((u) =>
u.id === upload.id
? { ...u, status: 'error' as const, error: errorMsg }
: u
)
)
})
})
},
[sessionId, onFilesChange]
)
const handlePaste = useCallback(
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
if (disabled) return
const items = e.clipboardData?.items
if (!items) return
const imageFiles: File[] = []
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (item.type.startsWith('image/')) {
const file = item.getAsFile()
if (file) imageFiles.push(file)
}
}
if (imageFiles.length > 0) {
e.preventDefault()
processFiles(imageFiles)
}
},
[disabled, processFiles]
)
const handleDragOver = useCallback(
(e: React.DragEvent) => {
if (disabled) return
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
},
[disabled]
)
const handleDragEnter = useCallback(
(e: React.DragEvent) => {
if (disabled) return
e.preventDefault()
dragCounterRef.current++
if (dragCounterRef.current === 1) {
setIsDragOver(true)
}
},
[disabled]
)
const handleDragLeave = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
dragCounterRef.current--
if (dragCounterRef.current === 0) {
setIsDragOver(false)
}
},
[]
)
const handleDrop = useCallback(
(e: React.DragEvent) => {
if (disabled) return
e.preventDefault()
dragCounterRef.current = 0
setIsDragOver(false)
const files = Array.from(e.dataTransfer.files)
processFiles(files)
},
[disabled, processFiles]
)
const handleRemove = useCallback(
(uploadId: string) => {
setPendingUploads((prev) => {
const toRemove = prev.find((u) => u.id === uploadId)
if (toRemove) {
URL.revokeObjectURL(toRemove.preview)
}
const updated = prev.filter((u) => u.id !== uploadId)
const completed = updated
.filter((u) => u.status === 'done' && u.result)
.map((u) => u.result!)
onFilesChange?.(completed)
return updated
})
},
[onFilesChange]
)
const retryUpload = useCallback(
(uploadId: string) => {
setPendingUploads((prev) =>
prev.map((u) => (u.id === uploadId ? { ...u, status: 'uploading' as const, error: undefined } : u))
)
const upload = pendingUploads.find((u) => u.id === uploadId)
if (!upload) return
uploadsApi
.upload(upload.file, sessionId)
.then((result) => {
setPendingUploads((prev) => {
const updated = prev.map((u) =>
u.id === uploadId ? { ...u, status: 'done' as const, result } : u
)
const completed = updated
.filter((u) => u.status === 'done' && u.result)
.map((u) => u.result!)
onFilesChange?.(completed)
return updated
})
})
.catch((err) => {
const errorMsg = err?.response?.status === 503
? 'File uploads not available — contact your administrator'
: err?.message || 'Upload failed'
setPendingUploads((prev) =>
prev.map((u) =>
u.id === uploadId
? { ...u, status: 'error' as const, error: errorMsg }
: u
)
)
})
},
[pendingUploads, sessionId, onFilesChange]
)
return (
<div
className={cn('relative', className)}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onPaste={handlePaste}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && onSubmit) {
e.preventDefault()
onSubmit()
}
}}
placeholder={placeholder}
rows={rows}
disabled={disabled}
className={cn(
'w-full bg-card border border-border rounded-xl p-3 text-sm text-foreground placeholder:text-muted-foreground',
'focus:border-[rgba(96,165,250,0.3)] focus:outline-none resize-none transition-colors',
isDragOver && 'border-primary/50 bg-primary/5',
disabled && 'opacity-50 cursor-not-allowed'
)}
/>
{/* Drag overlay hint */}
{isDragOver && (
<div className="absolute inset-0 flex items-center justify-center rounded-xl border-2 border-dashed border-primary/50 bg-primary/5 pointer-events-none">
<div className="flex items-center gap-2 text-sm text-primary">
<ImagePlus size={16} />
Drop image to attach
</div>
</div>
)}
{/* Thumbnail strip */}
{pendingUploads.length > 0 && (
<div className="flex gap-2 flex-wrap mt-2">
{pendingUploads.map((upload) => (
<div key={upload.id} className="relative w-16 h-16 rounded-lg overflow-hidden border border-border">
<img src={upload.preview} alt="" className="w-full h-full object-cover" />
{upload.status === 'uploading' && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center">
<Loader2 size={16} className="animate-spin text-primary" />
</div>
)}
{upload.status === 'done' && (
<button
onClick={() => handleRemove(upload.id)}
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-background/80 border border-border flex items-center justify-center hover:bg-rose-500/20 transition-colors"
>
<X size={10} className="text-muted-foreground" />
</button>
)}
{upload.status === 'error' && (
<div
className="absolute inset-0 bg-rose-500/20 border-2 border-rose-500 flex items-center justify-center cursor-pointer"
onClick={() => retryUpload(upload.id)}
>
<RotateCcw size={14} className="text-rose-500" />
</div>
)}
</div>
))}
</div>
)}
{/* Paste hint */}
{isFocused && !value && pendingUploads.length === 0 && (
<p className="text-[0.625rem] text-muted-foreground/50 mt-1">Paste screenshots with Ctrl+V</p>
)}
</div>
)
}