'use client' import * as React from 'react' import { Paperclip, RotateCw, SendHorizonal, X } from 'lucide-react' import { cn } from '../../lib/utils' import type { PendingAttachment } from './types' import { formatBytes, isImageName } from './attachment-utils' import { useComposerUploads, type AttachmentUploader, type UploadedAttachmentRef, } from './useComposerUploads' export interface ChatComposerProps { /** Controlled value. Omit for uncontrolled usage. */ value?: string onChange?: (value: string) => void /** Called with the trimmed message text on submit. */ onSubmit: (text: string) => void placeholder?: string /** Disables input entirely. */ disabled?: boolean /** Submission in flight — input stays editable but submit is blocked. */ busy?: boolean submitLabel?: string /** Max rows the textarea grows to before scrolling. Default 10. */ maxRows?: number /** * Controlled list of pending attachments rendered as chips above the textarea. * The APP owns upload state; the composer is presentation-only here. */ attachments?: PendingAttachment[] /** * Fired with the selected files from the attach button, a drag-and-drop onto * the composer, or a clipboard paste of image/file data. Presence of this * handler enables the attach affordances. */ onAttachFiles?: (files: File[]) => void /** Remove a pending attachment chip by id. */ onRemoveAttachment?: (id: string) => void /** Retry a failed attachment chip by id (shows a retry control on error chips). */ onRetryAttachment?: (id: string) => void /** * SELF-MANAGED attachment mode. When provided, the composer drives the entire * upload lifecycle itself (queued/uploading/ready/error chips, downscale, * retry, remove + server-delete, new-deck queue-then-flush-on-`documentId`) by * routing the attach button / drag-drop / paste files through this uploader. * The app's `@startsimpli/api` `AttachmentsApi` structurally satisfies it. * * In this mode the controlled props (`attachments`/`onAttachFiles`/ * `onRemoveAttachment`/`onRetryAttachment`) are ignored — the composer owns * the chip state. `onSubmit` still carries text only. */ uploader?: AttachmentUploader /** * Document the uploads land on (self-managed mode). May be absent/'new' for a * brand-new document: files are held client-side and auto-upload the moment a * real id arrives. */ documentId?: string /** Upload purpose tag for self-managed uploads. Default 'reference'. */ attachmentPurpose?: string /** Self-managed mode: fired with the uploaded attachment refs when they change. */ onAttachmentsChange?: (refs: UploadedAttachmentRef[]) => void className?: string } /** * Chat input with auto-growing textarea. Enter submits, Shift+Enter inserts a * newline, and large pastes are accepted as-is. Controlled or uncontrolled. * * Reference attachments are supported two ways (submit always carries text only): * * - SELF-MANAGED (recommended, one line): pass an `uploader` (+ `documentId`). * The composer drives the full upload lifecycle internally via * `useComposerUploads` — downscale, progress, retry, remove + server-delete, * and new-deck queue-then-flush — routing attach button / drag-drop / paste * files through the uploader. The app wires nothing else. * * - CONTROLLED (legacy): the app owns upload state and feeds `attachments` * down; the composer renders chips and surfaces new files via `onAttachFiles` * (`onRemoveAttachment`/`onRetryAttachment` for chip actions). * * Providing `uploader` switches to self-managed and ignores the controlled props. */ export function ChatComposer({ value, onChange, onSubmit, placeholder = 'Message…', disabled = false, busy = false, submitLabel = 'Send', maxRows = 10, attachments, onAttachFiles, onRemoveAttachment, onRetryAttachment, uploader, documentId, attachmentPurpose, onAttachmentsChange, className, }: ChatComposerProps) { const isControlled = value !== undefined const [internal, setInternal] = React.useState('') const text = isControlled ? value! : internal const textareaRef = React.useRef(null) const fileInputRef = React.useRef(null) const [dragOver, setDragOver] = React.useState(false) // SELF-MANAGED mode: when an `uploader` is supplied, the composer owns the // upload state and feeds its own chips/handlers in place of the controlled // props. The hook runs unconditionally (Rules of Hooks); a no-op uploader // keeps it inert when in controlled mode. const selfManaged = !!uploader const noopUploader = React.useRef({ async uploadAttachment() { return { id: '', fileUrl: null } }, async deleteAttachment() { /* noop */ }, }) const uploads = useComposerUploads(uploader ?? noopUploader.current, documentId, { purpose: attachmentPurpose, onAttachmentsChange, }) // Resolve effective chips/handlers from whichever mode is active. const effectiveAttachments = selfManaged ? uploads.attachments : attachments const effectiveAttachFiles = selfManaged ? uploads.attachFiles : onAttachFiles const effectiveRemove = selfManaged ? uploads.removeAttachment : onRemoveAttachment const effectiveRetry = selfManaged ? uploads.retryAttachment : onRetryAttachment const attachEnabled = !!effectiveAttachFiles && !disabled const chips = effectiveAttachments ?? [] const setText = (next: string) => { if (!isControlled) setInternal(next) onChange?.(next) } // auto-grow React.useEffect(() => { const el = textareaRef.current if (!el) return el.style.height = 'auto' const lineHeight = 24 el.style.height = `${Math.min(el.scrollHeight, lineHeight * maxRows)}px` }, [text, maxRows]) const canSubmit = !disabled && !busy && text.trim().length > 0 const submit = () => { if (!canSubmit) return onSubmit(text.trim()) if (!isControlled) setInternal('') // Self-managed chips have been (or are being) uploaded to the document; the // consumer reads them off the server on its turn, so clear them from the // composer once the message goes out. (Controlled mode leaves chip lifecycle // entirely to the app.) if (selfManaged) uploads.clear() } const onKeyDown = (e: React.KeyboardEvent) => { if (e.key !== 'Enter') return // Cmd/Ctrl+Enter always submits (handy for multi-line content). if (e.metaKey || e.ctrlKey) { e.preventDefault() submit() return } if (e.shiftKey) return // explicit newline // Bare Enter submits ONLY single-line content. When the value already // spans multiple lines (e.g. a pasted outline) Enter inserts a newline so // the multi-line block stays intact — submit via the button or Cmd+Enter. if (text.includes('\n')) return e.preventDefault() submit() } const emitFiles = (list: FileList | null | undefined) => { if (!attachEnabled || !list || list.length === 0) return effectiveAttachFiles!(Array.from(list)) } const onPickClick = () => fileInputRef.current?.click() const onInputChange = (e: React.ChangeEvent) => { emitFiles(e.target.files) // reset so picking the same file again re-fires change e.target.value = '' } const onDrop = (e: React.DragEvent) => { if (!attachEnabled) return const files = e.dataTransfer?.files if (files && files.length > 0) { e.preventDefault() setDragOver(false) emitFiles(files) } } const onDragOver = (e: React.DragEvent) => { if (!attachEnabled) return // Only treat as a file drag (not text selection drags). if (Array.from(e.dataTransfer?.types ?? []).includes('Files')) { e.preventDefault() setDragOver(true) } } const onDragLeave = () => setDragOver(false) const onPaste = (e: React.ClipboardEvent) => { if (!attachEnabled) return const files = e.clipboardData?.files if (files && files.length > 0) { // Pasted image/file data — capture it as an attachment, not text. e.preventDefault() emitFiles(files) } } return (
{chips.length > 0 && (
    {chips.map((a) => ( ))}
)}
{attachEnabled && ( <> )}