11'use client'
22
33import { useCallback , useEffect , useRef , useState } from 'react'
4- import { ArrowUp , Mic , Paperclip } from 'lucide-react'
4+ import { ArrowUp , FileText , Loader2 , Mic , Paperclip , X } from 'lucide-react'
55import { Button } from '@/components/emcn'
66import { cn } from '@/lib/core/utils/cn'
7+ import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
78import { useAnimatedPlaceholder } from '../../hooks'
89
910const TEXTAREA_CLASSES = cn (
@@ -27,13 +28,25 @@ function autoResizeTextarea(e: React.FormEvent<HTMLTextAreaElement>) {
2728 target . style . height = `${ Math . min ( target . scrollHeight , window . innerHeight * 0.3 ) } px`
2829}
2930
31+ export interface FileAttachmentForApi {
32+ id : string
33+ key : string
34+ filename : string
35+ media_type : string
36+ size : number
37+ }
38+
39+ const ACCEPTED_FILE_TYPES =
40+ 'image/*,.pdf,.txt,.csv,.md,.html,.json,.xml,text/plain,text/csv,text/markdown,text/html,application/json,application/xml,application/pdf'
41+
3042interface UserInputProps {
3143 value : string
3244 onChange : ( value : string ) => void
33- onSubmit : ( ) => void
45+ onSubmit : ( fileAttachments ?: FileAttachmentForApi [ ] ) => void
3446 isSending : boolean
3547 onStopGeneration : ( ) => void
3648 isInitialView ?: boolean
49+ userId ?: string
3750}
3851
3952export function UserInput ( {
@@ -43,10 +56,14 @@ export function UserInput({
4356 isSending,
4457 onStopGeneration,
4558 isInitialView = true ,
59+ userId,
4660} : UserInputProps ) {
4761 const animatedPlaceholder = useAnimatedPlaceholder ( )
4862 const placeholder = isInitialView ? animatedPlaceholder : 'Send message to Sim'
49- const canSubmit = value . trim ( ) . length > 0 && ! isSending
63+
64+ const files = useFileAttachments ( { userId, disabled : false , isLoading : isSending } )
65+ const hasFiles = files . attachedFiles . some ( ( f ) => ! f . uploading && f . key )
66+ const canSubmit = ( value . trim ( ) . length > 0 || hasFiles ) && ! isSending
5067
5168 const [ isListening , setIsListening ] = useState ( false )
5269 const recognitionRef = useRef < SpeechRecognition | null > ( null )
@@ -65,14 +82,29 @@ export function UserInput({
6582 textareaRef . current ?. focus ( )
6683 } , [ ] )
6784
85+ const handleSubmit = useCallback ( ( ) => {
86+ const fileAttachmentsForApi = files . attachedFiles
87+ . filter ( ( f ) => ! f . uploading && f . key )
88+ . map ( ( f ) => ( {
89+ id : f . id ,
90+ key : f . key ! ,
91+ filename : f . name ,
92+ media_type : f . type ,
93+ size : f . size ,
94+ } ) )
95+
96+ onSubmit ( fileAttachmentsForApi . length > 0 ? fileAttachmentsForApi : undefined )
97+ files . clearAttachedFiles ( )
98+ } , [ onSubmit , files ] )
99+
68100 const handleKeyDown = useCallback (
69101 ( e : React . KeyboardEvent < HTMLTextAreaElement > ) => {
70102 if ( e . key === 'Enter' && ! e . shiftKey ) {
71103 e . preventDefault ( )
72- onSubmit ( )
104+ handleSubmit ( )
73105 }
74106 } ,
75- [ onSubmit ]
107+ [ handleSubmit ]
76108 )
77109
78110 const toggleListening = useCallback ( ( ) => {
@@ -133,26 +165,89 @@ export function UserInput({
133165 onClick = { handleContainerClick }
134166 className = { cn (
135167 'mx-auto w-full max-w-[640px] cursor-text rounded-[20px] border border-[var(--border-1)] bg-[var(--white)] px-[10px] py-[8px] dark:bg-[var(--surface-4)]' ,
136- isInitialView && 'shadow-sm'
168+ isInitialView && 'shadow-sm' ,
169+ files . isDragging && 'ring-[1.75px] ring-[var(--brand-secondary)]'
137170 ) }
171+ onDragEnter = { files . handleDragEnter }
172+ onDragLeave = { files . handleDragLeave }
173+ onDragOver = { files . handleDragOver }
174+ onDrop = { files . handleDrop }
138175 >
176+ { /* Attached files */ }
177+ { files . attachedFiles . length > 0 && (
178+ < div className = 'mb-[6px] flex gap-[6px] overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden' >
179+ { files . attachedFiles . map ( ( file ) => {
180+ const isImage = file . type . startsWith ( 'image/' )
181+ return (
182+ < div
183+ key = { file . id }
184+ className = 'group relative h-[56px] w-[56px] flex-shrink-0 cursor-pointer overflow-hidden rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-5)] transition-all hover:bg-[var(--surface-4)]'
185+ title = { `${ file . name } (${ files . formatFileSize ( file . size ) } )` }
186+ onClick = { ( ) => files . handleFileClick ( file ) }
187+ >
188+ { isImage && file . previewUrl ? (
189+ < img
190+ src = { file . previewUrl }
191+ alt = { file . name }
192+ className = 'h-full w-full object-cover'
193+ />
194+ ) : (
195+ < div className = 'flex h-full w-full flex-col items-center justify-center gap-[2px]' >
196+ { file . type . includes ( 'pdf' ) ? (
197+ < FileText className = 'h-[18px] w-[18px] text-red-500' />
198+ ) : (
199+ < FileText className = 'h-[18px] w-[18px] text-blue-500' />
200+ ) }
201+ < span className = 'max-w-[48px] truncate px-[2px] text-[9px] text-[var(--text-muted)]' >
202+ { file . name . split ( '.' ) . pop ( ) }
203+ </ span >
204+ </ div >
205+ ) }
206+ { file . uploading && (
207+ < div className = 'absolute inset-0 flex items-center justify-center bg-black/50' >
208+ < Loader2 className = 'h-[14px] w-[14px] animate-spin text-white' />
209+ </ div >
210+ ) }
211+ { ! file . uploading && (
212+ < button
213+ type = 'button'
214+ onClick = { ( e ) => {
215+ e . stopPropagation ( )
216+ files . removeFile ( file . id )
217+ } }
218+ className = 'absolute top-[2px] right-[2px] flex h-[16px] w-[16px] items-center justify-center rounded-full bg-black/60 opacity-0 transition-opacity group-hover:opacity-100'
219+ >
220+ < X className = 'h-[10px] w-[10px] text-white' />
221+ </ button >
222+ ) }
223+ </ div >
224+ )
225+ } ) }
226+ </ div >
227+ ) }
228+
139229 < textarea
140230 ref = { textareaRef }
141231 value = { value }
142232 onChange = { ( e ) => onChange ( e . target . value ) }
143233 onKeyDown = { handleKeyDown }
144234 onInput = { autoResizeTextarea }
145- placeholder = { placeholder }
235+ placeholder = { files . isDragging ? 'Drop files here...' : placeholder }
146236 rows = { 1 }
147237 className = { TEXTAREA_CLASSES }
148238 />
149239 < div className = 'flex items-center justify-between' >
150- < div className = 'flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded-full border border-[#F0F0F0] transition-colors hover:bg-[#F7F7F7] dark:border-[#3d3d3d] dark:hover:bg-[#303030]' >
240+ < button
241+ type = 'button'
242+ onClick = { files . handleFileSelect }
243+ className = 'flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded-full border border-[#F0F0F0] transition-colors hover:bg-[#F7F7F7] dark:border-[#3d3d3d] dark:hover:bg-[#303030]'
244+ title = 'Attach file'
245+ >
151246 < Paperclip
152247 className = 'h-[14px] w-[14px] text-[var(--text-muted)] dark:text-[var(--text-secondary)]'
153248 strokeWidth = { 2 }
154249 />
155- </ div >
250+ </ button >
156251 < div className = 'flex items-center gap-[6px]' >
157252 < button
158253 type = 'button'
@@ -183,7 +278,7 @@ export function UserInput({
183278 </ Button >
184279 ) : (
185280 < Button
186- onClick = { onSubmit }
281+ onClick = { handleSubmit }
187282 disabled = { ! canSubmit }
188283 className = { cn (
189284 SEND_BUTTON_BASE ,
@@ -198,6 +293,16 @@ export function UserInput({
198293 ) }
199294 </ div >
200295 </ div >
296+
297+ { /* Hidden file input */ }
298+ < input
299+ ref = { files . fileInputRef }
300+ type = 'file'
301+ onChange = { files . handleFileChange }
302+ className = 'hidden'
303+ accept = { ACCEPTED_FILE_TYPES }
304+ multiple
305+ />
201306 </ div >
202307 )
203308}
0 commit comments