import React, { useState, useRef, useCallback, useMemo, forwardRef, useEffect, useId, } from 'react' import { ChatInputProps, TopQuery } from './types' import Block from '../Primitives/Block/Block' import PrimitiveButton from '../Primitives/PrimitiveButton/PrimitiveButton' import Button from '../Button/Button' import { ButtonType, ButtonSize, ButtonSubType } from '../Button/types' import Text from '../Text/Text' import { Paperclip } from 'lucide-react' import { ChatInputTokensType } from './chatInput.tokens' import { FOUNDATION_THEME } from '../../tokens' import { handleAutoResize, isValidMessageLength, filterDuplicateFiles, } from './utils' import PrimitiveInput from '../Primitives/PrimitiveInput/PrimitiveInput' import { addSnackbar, SnackbarVariant } from '../Snackbar' import { BREAKPOINTS } from '../../breakpoints/breakPoints' import { useBreakpoints } from '../../hooks/useBreakPoints' import MobileChatInput from './MobileChatInput' import { useResponsiveTokens } from '../../hooks/useResponsiveTokens' import PrimitiveTextarea from '../Primitives/PrimitiveTextArea' import AttachmentFile from './AttachmentFile' export { getDocIcon } from './getDocIcon' const ChatInput = forwardRef( ( { value = '', slot1, slot2, onChange, onAttachFiles, onFileRemove, onFileClick, placeholder = 'Type a message...', disabled = false, maxLength, autoResize = true, attachedFiles = [], topQueries = [], onTopQuerySelect, topQueriesMaxHeight = 200, attachButtonIcon, overflowMenuProps, 'aria-label': ariaLabel, 'aria-describedby': ariaDescribedBy, ...textAreaProps }, ref ) => { const tokens = useResponsiveTokens('CHAT_INPUT') const textareaRef = useRef(null) const fileInputRef = useRef(null) const containerRef = useRef(null!) const [isTextareaFocused, setIsTextareaFocused] = useState(false) const [focusedQueryIndex, setFocusedQueryIndex] = useState(-1) const generatedId = useId() const chatInputId = `chat-input-${generatedId}` const topQueriesId = `chat-input-queries-${generatedId}` const characterCountId = `chat-input-count-${generatedId}` const fileInputLabelId = `chat-input-file-label-${generatedId}` const textareaElement = (ref as React.RefObject) || textareaRef const { innerWidth } = useBreakpoints() const isMobile = innerWidth < BREAKPOINTS.lg const handleTextareaChange = useCallback( (e: React.ChangeEvent) => { const newValue = e.target.value if (!isValidMessageLength(newValue, maxLength)) return onChange?.(newValue) handleAutoResize(textareaElement.current, autoResize) }, [onChange, maxLength, autoResize, textareaElement] ) const handleAttachClick = useCallback(() => { if (disabled) return fileInputRef.current?.click() }, [disabled]) const handleFileInputChange = useCallback( (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []) if (files.length > 0) { // Filter out duplicate files const { newFiles, duplicateFiles } = filterDuplicateFiles( files, attachedFiles ) // Show snackbar if duplicates were found if (duplicateFiles.length > 0) { const duplicateCount = duplicateFiles.length const message = duplicateCount === 1 ? `File "${duplicateFiles[0]}" is already attached` : `${duplicateCount} duplicate file(s) were not added` addSnackbar({ header: 'Duplicate File', description: message, variant: SnackbarVariant.WARNING, duration: 3000, }) } // Only call onAttachFiles with non-duplicate files if (newFiles.length > 0) { onAttachFiles?.(newFiles) } } // Reset the input value to allow selecting the same file again e.target.value = '' }, [onAttachFiles, attachedFiles] ) const handleTopQueryClick = useCallback( (query: TopQuery) => { onTopQuerySelect?.(query) setIsTextareaFocused(false) setFocusedQueryIndex(-1) }, [onTopQuerySelect] ) const handleTopQueryKeyDown = useCallback( (e: React.KeyboardEvent, query: TopQuery) => { if (disabled) return switch (e.key) { case 'Enter': case ' ': e.preventDefault() handleTopQueryClick(query) break case 'ArrowDown': e.preventDefault() setFocusedQueryIndex((prev) => prev < topQueries.length - 1 ? prev + 1 : prev ) break case 'ArrowUp': e.preventDefault() setFocusedQueryIndex((prev) => prev > 0 ? prev - 1 : -1 ) break case 'Escape': e.preventDefault() setIsTextareaFocused(false) setFocusedQueryIndex(-1) textareaElement.current?.focus() break case 'Home': e.preventDefault() setFocusedQueryIndex(0) break case 'End': e.preventDefault() setFocusedQueryIndex(topQueries.length - 1) break } }, [disabled, topQueries.length, handleTopQueryClick, textareaElement] ) useEffect(() => { if (focusedQueryIndex >= 0 && topQueries.length > 0) { const queryElement = document.querySelector( `[data-query-index="${focusedQueryIndex}"]` ) as HTMLElement queryElement?.focus() } else if (topQueries.length === 0) { setFocusedQueryIndex(-1) } }, [focusedQueryIndex, topQueries.length]) const textareaAriaDescribedBy = useMemo(() => { const parts = [ ariaDescribedBy, maxLength && characterCountId, ].filter(Boolean) return parts.length > 0 ? parts.join(' ') : undefined }, [ariaDescribedBy, maxLength, characterCountId]) const hiddenFileInput = ( ) if (isMobile) { return ( <> {hiddenFileInput} {})} slot1={slot1} slot2={slot2} placeholder={placeholder} attachedFiles={attachedFiles} handleAttachClick={handleAttachClick} onFileRemove={onFileRemove} onFileClick={onFileClick} overflowMenuProps={overflowMenuProps} // containerRef={containerRef} /> ) } return ( 0 || slot2 ? FOUNDATION_THEME.colors.gray[50] : tokens.container.backgroundColor.default } border={tokens.container.border.default} borderRadius={tokens.container.borderRadius} padding={tokens.container.padding} gap={tokens.container.gap} minHeight={tokens.container.minHeight} position="relative" transition={tokens.container.transition} cursor={disabled ? 'not-allowed' : 'default'} boxShadow={tokens.container.boxShadow.default} > {hiddenFileInput} {/* Files container */} {attachedFiles.length > 0 && ( )} {slot2} 0 || slot2 ? tokens.attachmentContainer.borderRadius : isTextareaFocused ? tokens.attachmentContainer.borderRadius : 0 } overflow="hidden" > 0 || slot2 ? tokens.attachmentContainer.backgroundColor : 'transparent' } > maxLength ? true : undefined } id={chatInputId} rows={1} backgroundColor={tokens.textarea.backgroundColor} color={tokens.textarea.color} fontSize={tokens.textarea.fontSize} paddingTop={tokens.textarea.paddingTop} paddingRight={tokens.textarea.paddingRight} paddingLeft={tokens.textarea.paddingLeft} border={tokens.textarea.border} borderRadius={tokens.textarea.borderRadius} outline="none" resize={tokens.textarea.resize} fontFamily={tokens.textarea.fontFamily} width="100%" minHeight={tokens.textarea.minHeight} maxHeight={tokens.textarea.maxHeight} overflowY={tokens.textarea.overflowY} cursor={disabled ? 'not-allowed' : 'text'} style={{ lineHeight: tokens.textarea.lineHeight }} onFocus={(e) => { setIsTextareaFocused(true) const container = e.currentTarget.parentElement! .parentElement! container.style.outline = tokens.container .border.focus as string container.style.boxShadow = tokens.container .boxShadow.focus as string }} onBlur={(e) => { setIsTextareaFocused(false) const container = e.currentTarget.parentElement! .parentElement! container.style.boxShadow = 'none' container.style.outline = 'none' }} {...textAreaProps} />