import { forwardRef, useImperativeHandle, useLayoutEffect, useRef, useState, type ChangeEvent, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, } from "react"; import { AlertTriangle, Check, Loader2, Paperclip, Send } from "lucide-react"; import { cn } from "../lib/utils"; /** * Shared chat composer (PAP-95a / PAP-96). * * One reusable input shell used by BOTH the conference room (BoardChat) and * task comments (IssueChatThread). It is intentionally a *plain textarea* — * **no formatting toolbar** — with attach + send. The focus state is a neutral * border darkening (no blue focus ring) so the box reads as calm chrome in both * surfaces. * * Task-only chrome — the yellow planning chip and the assignee footer — is NOT * baked in here. It layers on through the `tone`, `leadingTools`, and * `trailingTools` props (wired by PAP-95b). The @project/@task picker layers on * through PAP-95f. The conference room adopts this bare: textarea + send (+ attach * when a handler is supplied), no mode chip, since the room has no task lifecycle. */ export interface ChatComposerAttachment { id: string; name: string; size?: number; status: "uploading" | "attached" | "error"; error?: string; /** True when the file was inserted inline (e.g. an image) rather than only attached. */ inline?: boolean; } export interface ChatComposerHandle { focus: () => void; } export interface ChatComposerProps { value: string; onChange: (value: string) => void; onSubmit: () => void; placeholder?: string; disabled?: boolean; /** Shows the send button in a busy state and blocks resubmission. */ submitting?: boolean; /** * Send-key behavior. * - `"enter"`: Enter submits, Shift+Enter inserts a newline (conference room default today). * - `"mod-enter"`: Cmd/Ctrl+Enter submits, Enter inserts a newline (recommended unified default). */ submitKey?: "enter" | "mod-enter"; /** Collapse to a single visual line — strips newlines and disables wrapping (conference room). */ singleLine?: boolean; /** Visual tone. Task issue modes tint the box for planning and ask flows. */ tone?: "standard" | "ask" | "planning"; /** * Surface treatment (PAP-128 A / PAP-131). * - `"card"`: opaque `bg-card` box (default). * - `"translucent"`: the task-comments glass recipe — translucent background, * backdrop blur, and a soft upward shadow — so chat text reads through the * box as it scrolls behind (mirrors `IssueChatThread.tsx` composer shell). */ surface?: "card" | "translucent"; autoFocus?: boolean; /** Accessible label for the send button. */ sendLabel?: string; /** * When provided, an attach button, drag-and-drop, and the attachment chip list * render. The parent owns the actual upload and reflects progress back through * the `attachments` prop. Omit entirely to render bare (no attach affordance). */ onAttachFiles?: (files: File[]) => void | Promise; attachments?: ChatComposerAttachment[]; attaching?: boolean; /** Restrict the file picker (e.g. "image/*"). */ acceptFileTypes?: string; /** Slot rendered just right of the attach button (e.g. the planning mode chip). */ leadingTools?: ReactNode; /** Slot rendered just left of the send button (e.g. the assignee picker). */ trailingTools?: ReactNode; /** Optional hint chip rendered under the textarea. */ hint?: ReactNode; className?: string; textareaClassName?: string; /** test id for the textarea (defaults to "chat-composer-input"). */ inputTestId?: string; } const MAX_TEXTAREA_HEIGHT_PX = 200; function formatAttachmentSize(bytes: number | undefined): string { if (!bytes || bytes <= 0) return ""; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function dragHasFiles(evt: ReactDragEvent): boolean { const types = evt.dataTransfer?.types; if (!types) return false; return Array.from(types).includes("Files"); } export const ChatComposer = forwardRef(function ChatComposer( { value, onChange, onSubmit, placeholder = "Message…", disabled = false, submitting = false, submitKey = "mod-enter", singleLine = false, tone = "standard", surface = "card", autoFocus = false, sendLabel = "Send message", onAttachFiles, attachments = [], attaching = false, acceptFileTypes, leadingTools, trailingTools, hint, className, textareaClassName, inputTestId = "chat-composer-input", }, forwardedRef, ) { const textareaRef = useRef(null); const fileInputRef = useRef(null); const dragDepthRef = useRef(0); const [isDragOver, setIsDragOver] = useState(false); const canAttach = typeof onAttachFiles === "function"; const isAsk = tone === "ask"; const isPlanning = tone === "planning"; const canSend = !disabled && !submitting && value.trim().length > 0; useImperativeHandle(forwardedRef, () => ({ focus: () => textareaRef.current?.focus(), }), []); // Auto-grow the textarea up to a cap (multiline only). Single-line stays at // one row and scrolls horizontally, matching the conference room today. useLayoutEffect(() => { const el = textareaRef.current; if (!el || singleLine) return; el.style.height = "auto"; el.style.height = `${Math.min(el.scrollHeight, MAX_TEXTAREA_HEIGHT_PX)}px`; }, [value, singleLine]); function handleChange(evt: ChangeEvent) { const next = singleLine ? evt.target.value.replace(/\r?\n/g, " ") : evt.target.value; onChange(next); } function handleKeyDown(evt: ReactKeyboardEvent) { if (evt.key !== "Enter") return; const wantsSubmit = submitKey === "mod-enter" ? evt.metaKey || evt.ctrlKey : !evt.shiftKey && !evt.metaKey && !evt.ctrlKey; if (!wantsSubmit) return; evt.preventDefault(); if (canSend) onSubmit(); } function triggerFilePicker() { fileInputRef.current?.click(); } async function emitFiles(files: File[]) { if (!onAttachFiles || files.length === 0) return; await onAttachFiles(files); } async function handleFileInputChange(evt: ChangeEvent) { const files = evt.target.files ? Array.from(evt.target.files) : []; await emitFiles(files); if (fileInputRef.current) fileInputRef.current.value = ""; } function resetDrag() { dragDepthRef.current = 0; setIsDragOver(false); } function handleDragEnter(evt: ReactDragEvent) { if (!canAttach || !dragHasFiles(evt)) return; evt.preventDefault(); dragDepthRef.current += 1; setIsDragOver(true); } function handleDragOver(evt: ReactDragEvent) { if (!canAttach || !dragHasFiles(evt)) return; evt.preventDefault(); evt.dataTransfer.dropEffect = "copy"; } function handleDragLeave(evt: ReactDragEvent) { if (!canAttach || !dragHasFiles(evt)) return; evt.preventDefault(); dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); if (dragDepthRef.current === 0) setIsDragOver(false); } function handleDrop(evt: ReactDragEvent) { if (!canAttach || !dragHasFiles(evt)) return; evt.preventDefault(); resetDrag(); const files = evt.dataTransfer?.files ? Array.from(evt.dataTransfer.files) : []; void emitFiles(files); } return (
{isDragOver && canAttach ? (
Drop to attach
) : null}