"use client" import { BrainIcon, CheckIcon, ChevronUpIcon, Loader2Icon, PaperclipIcon, RotateCcwIcon, XIcon, ZapIcon, } from "lucide-react" import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog" import NovaOrb from "@/components/nova/nova-orb" import { SuperLoader } from "@/components/superloader" import { cn } from "@lib/utils" import { dmSansClassName } from "@/lib/fonts" import { type ReactNode, useEffect, useRef, useState } from "react" import { AnimatePresence, motion } from "motion/react" import { SendButton, StopButton } from "./actions" import { CHAT_ATTACHMENT_ACCEPT, type ChatAttachmentDraft, formatAttachmentSize, } from "../attachments" import { type ModelId, modelNames, type ReasoningEffort } from "@/lib/models" export interface QueuedChatMessagePreview { id: string text: string model: ModelId reasoningEffort: ReasoningEffort } interface ChatInputProps { value: string onChange: (e: React.ChangeEvent) => void onSend: () => void onStop: () => void onKeyDown?: (e: React.KeyboardEvent) => void isResponding?: boolean sendDisabled?: boolean sendDisabledTooltip?: string activeStatus?: string queuedMessages?: QueuedChatMessagePreview[] chainOfThoughtComponent?: React.ReactNode onExpandedChange?: (expanded: boolean) => void /** Model + space controls on one row with send; textarea full-width above */ stackedToolbar?: ReactNode /** Trailing controls rendered after the attach button (e.g. reasoning) */ toolbarTrailing?: ReactNode /** Controls rendered just left of the send button (e.g. space selector) */ toolbarEnd?: ReactNode /** Nova status row + chain-of-thought toggle (off for e.g. home composer) */ showStatusStrip?: boolean attachments?: ChatAttachmentDraft[] onAddAttachmentFiles?: (files: FileList | File[]) => void onRemoveAttachment?: (id: string) => void onRetryAttachment?: (id: string) => void canSend?: boolean attachmentAccept?: string disableFileDropZone?: boolean } export default function ChatInput({ value, onChange, onSend, onStop, onKeyDown, isResponding = false, sendDisabled = false, sendDisabledTooltip, activeStatus, queuedMessages = [], chainOfThoughtComponent, onExpandedChange, stackedToolbar, toolbarTrailing, toolbarEnd, showStatusStrip = true, attachments = [], onAddAttachmentFiles, onRemoveAttachment, onRetryAttachment, canSend, attachmentAccept = CHAT_ATTACHMENT_ACCEPT, disableFileDropZone = false, }: ChatInputProps) { const [isMultiline, setIsMultiline] = useState(false) const [isExpanded, setIsExpanded] = useState(false) const [isDraggingFiles, setIsDraggingFiles] = useState(false) const textareaRef = useRef(null) const fileInputRef = useRef(null) const dragDepthRef = useRef(0) const canSubmit = canSend ?? value.trim().length > 0 const isSendDisabled = !canSubmit || sendDisabled const hasQueuedPreview = queuedMessages.length > 0 const resolvedSendDisabledTooltip = sendDisabled && canSubmit ? sendDisabledTooltip : "Type a message to send" useEffect(() => { if (!showStatusStrip && isExpanded) { setIsExpanded(false) onExpandedChange?.(false) } }, [isExpanded, onExpandedChange, showStatusStrip]) const handleChange = (e: React.ChangeEvent) => { onChange(e) const textarea = e.target textarea.style.height = "auto" // Set height based on scrollHeight, with a max of ~96px (4-5 lines) const newHeight = Math.min(textarea.scrollHeight, 96) textarea.style.height = `${newHeight}px` setIsMultiline(textarea.scrollHeight > 52) } const handleFileSelect = (e: React.ChangeEvent) => { const files = e.target.files if (files?.length) onAddAttachmentFiles?.(files) e.target.value = "" } const canAttachFiles = Boolean(onAddAttachmentFiles) && !isResponding const hasDraggedFiles = (e: React.DragEvent) => Array.from(e.dataTransfer.types).includes("Files") const handleDragEnter = (e: React.DragEvent) => { if (!hasDraggedFiles(e)) return e.preventDefault() e.stopPropagation() dragDepthRef.current += 1 if (canAttachFiles) setIsDraggingFiles(true) } const handleDragOver = (e: React.DragEvent) => { if (!hasDraggedFiles(e)) return e.preventDefault() e.stopPropagation() e.dataTransfer.dropEffect = canAttachFiles ? "copy" : "none" } const handleDragLeave = (e: React.DragEvent) => { if (!hasDraggedFiles(e)) return e.preventDefault() e.stopPropagation() dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) if (dragDepthRef.current === 0) setIsDraggingFiles(false) } const handleDrop = (e: React.DragEvent) => { if (!hasDraggedFiles(e)) return e.preventDefault() e.stopPropagation() dragDepthRef.current = 0 setIsDraggingFiles(false) const files = e.dataTransfer.files if (canAttachFiles && files.length) onAddAttachmentFiles?.(files) } const showAttachments = attachments.length > 0 const attachmentTray = showAttachments ? (
{attachments.map((attachment) => { return ( ) })}
) : null const attachmentButton = onAddAttachmentFiles ? ( <> ) : null const dropOverlay = !disableFileDropZone && isDraggingFiles ? (
Drop files to attach
) : null const dropZoneProps = disableFileDropZone ? {} : { onDragEnter: handleDragEnter, onDragOver: handleDragOver, onDragLeave: handleDragLeave, onDrop: handleDrop, } return ( {showStatusStrip ? ( <>
{chainOfThoughtComponent}
{hasQueuedPreview && (
{queuedMessages.map((queued) => { const model = modelNames[queued.model] const ReasoningIcon = queued.reasoningEffort === "thinking" ? BrainIcon : ZapIcon return (
{queued.text} {model.name} {model.version} ·
) })}
)} ) : null} {stackedToolbar ? (
{dropOverlay} {attachmentTray}