@@ -34,6 +34,103 @@ interface UploadedFile {
3434 type : string
3535}
3636
37+ interface SingleFileSelectorProps {
38+ file : UploadedFile
39+ options : Array < { label : string ; value : string ; disabled ?: boolean } >
40+ selectedValue : string
41+ inputValue : string
42+ onInputChange : ( value : string ) => void
43+ onClear : ( e : React . MouseEvent ) => void
44+ onOpenChange : ( open : boolean ) => void
45+ disabled : boolean
46+ isLoading : boolean
47+ formatFileSize : ( bytes : number ) => string
48+ truncateMiddle : ( text : string , start ?: number , end ?: number ) => string
49+ isDeleting : boolean
50+ }
51+
52+ /**
53+ * Single file selector component that shows the selected file with both
54+ * a clear button (X) and a chevron to change the selection.
55+ * Follows the same pattern as SelectorCombobox for consistency.
56+ */
57+ function SingleFileSelector ( {
58+ file,
59+ options,
60+ selectedValue,
61+ inputValue,
62+ onInputChange,
63+ onClear,
64+ onOpenChange,
65+ disabled,
66+ isLoading,
67+ formatFileSize,
68+ truncateMiddle,
69+ isDeleting,
70+ } : SingleFileSelectorProps ) {
71+ const displayLabel = `${ truncateMiddle ( file . name , 20 , 12 ) } (${ formatFileSize ( file . size ) } )`
72+ const [ localInputValue , setLocalInputValue ] = useState ( displayLabel )
73+ const [ isEditing , setIsEditing ] = useState ( false )
74+
75+ // Sync display label when file changes
76+ useEffect ( ( ) => {
77+ if ( ! isEditing ) {
78+ setLocalInputValue ( displayLabel )
79+ }
80+ } , [ displayLabel , isEditing ] )
81+
82+ return (
83+ < div className = 'relative w-full' >
84+ < Combobox
85+ options = { options }
86+ value = { localInputValue }
87+ selectedValue = { selectedValue }
88+ onChange = { ( newValue ) => {
89+ // Check if user selected an option
90+ const matched = options . find ( ( opt ) => opt . value === newValue || opt . label === newValue )
91+ if ( matched ) {
92+ setIsEditing ( false )
93+ setLocalInputValue ( displayLabel )
94+ onInputChange ( matched . value )
95+ return
96+ }
97+ // User is typing to search
98+ setIsEditing ( true )
99+ setLocalInputValue ( newValue )
100+ } }
101+ onOpenChange = { ( open ) => {
102+ if ( ! open ) {
103+ setIsEditing ( false )
104+ setLocalInputValue ( displayLabel )
105+ }
106+ onOpenChange ( open )
107+ } }
108+ placeholder = { isLoading ? 'Loading files...' : 'Select or upload file' }
109+ disabled = { disabled || isDeleting }
110+ editable = { true }
111+ filterOptions = { isEditing }
112+ isLoading = { isLoading }
113+ inputProps = { {
114+ className : 'pr-[60px]' ,
115+ } }
116+ />
117+ < Button
118+ type = 'button'
119+ variant = 'ghost'
120+ className = '-translate-y-1/2 absolute top-1/2 right-[28px] z-10 h-6 w-6 p-0'
121+ onClick = { onClear }
122+ disabled = { isDeleting }
123+ >
124+ { isDeleting ? (
125+ < div className = 'h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
126+ ) : (
127+ < X className = 'h-4 w-4 opacity-50 hover:opacity-100' />
128+ ) }
129+ </ Button >
130+ </ div >
131+ )
132+ }
133+
37134interface UploadingFile {
38135 id : string
39136 name : string
@@ -500,6 +597,7 @@ export function FileUpload({
500597 const hasFiles = filesArray . length > 0
501598 const isUploading = uploadingFiles . length > 0
502599
600+ // Options for multiple file mode (filters out already selected files)
503601 const comboboxOptions = useMemo (
504602 ( ) => [
505603 { label : 'Upload New File' , value : '__upload_new__' } ,
@@ -516,10 +614,43 @@ export function FileUpload({
516614 [ availableWorkspaceFiles , acceptedTypes ]
517615 )
518616
617+ // Options for single file mode (includes all files, selected one will be highlighted)
618+ const singleFileOptions = useMemo (
619+ ( ) => [
620+ { label : 'Upload New File' , value : '__upload_new__' } ,
621+ ...workspaceFiles . map ( ( file ) => {
622+ const isAccepted =
623+ ! acceptedTypes || acceptedTypes === '*' || isFileTypeAccepted ( file . type , acceptedTypes )
624+ return {
625+ label : file . name ,
626+ value : file . id ,
627+ disabled : ! isAccepted ,
628+ }
629+ } ) ,
630+ ] ,
631+ [ workspaceFiles , acceptedTypes ]
632+ )
633+
634+ // Find the selected file's workspace ID for highlighting in single file mode
635+ const selectedFileId = useMemo ( ( ) => {
636+ if ( ! hasFiles || multiple ) return ''
637+ const currentFile = filesArray [ 0 ]
638+ if ( ! currentFile ) return ''
639+ // Match by key or path
640+ const matchedWorkspaceFile = workspaceFiles . find (
641+ ( wf ) =>
642+ wf . key === currentFile . key ||
643+ wf . name === currentFile . name ||
644+ currentFile . path ?. includes ( wf . key )
645+ )
646+ return matchedWorkspaceFile ?. id || ''
647+ } , [ filesArray , workspaceFiles , hasFiles , multiple ] )
648+
519649 const handleComboboxChange = ( value : string ) => {
520650 setInputValue ( value )
521651
522- const selectedFile = availableWorkspaceFiles . find ( ( file ) => file . id === value )
652+ // Look in full workspaceFiles list (not filtered) to allow re-selecting same file in single mode
653+ const selectedFile = workspaceFiles . find ( ( file ) => file . id === value )
523654 const isAcceptedType =
524655 selectedFile &&
525656 ( ! acceptedTypes ||
@@ -559,16 +690,17 @@ export function FileUpload({
559690 { /* Error message */ }
560691 { uploadError && < div className = 'mb-2 text-red-600 text-sm' > { uploadError } </ div > }
561692
562- { /* File list with consistent spacing */ }
563- { ( hasFiles || isUploading ) && (
693+ { /* File list with consistent spacing - only show for multiple mode or when uploading */ }
694+ { ( ( hasFiles && multiple ) || isUploading ) && (
564695 < div className = { cn ( 'space-y-2' , multiple && 'mb-2' ) } >
565- { /* Only show files that aren't currently uploading */ }
566- { filesArray . map ( ( file ) => {
567- const isCurrentlyUploading = uploadingFiles . some (
568- ( uploadingFile ) => uploadingFile . name === file . name
569- )
570- return ! isCurrentlyUploading && renderFileItem ( file )
571- } ) }
696+ { /* Only show files that aren't currently uploading (for multiple mode only) */ }
697+ { multiple &&
698+ filesArray . map ( ( file ) => {
699+ const isCurrentlyUploading = uploadingFiles . some (
700+ ( uploadingFile ) => uploadingFile . name === file . name
701+ )
702+ return ! isCurrentlyUploading && renderFileItem ( file )
703+ } ) }
572704 { isUploading && (
573705 < >
574706 { uploadingFiles . map ( renderUploadingItem ) }
@@ -604,6 +736,26 @@ export function FileUpload({
604736 />
605737 ) }
606738
739+ { /* Single file mode with file selected: show combobox-style UI with X and chevron */ }
740+ { hasFiles && ! multiple && ! isUploading && (
741+ < SingleFileSelector
742+ file = { filesArray [ 0 ] }
743+ options = { singleFileOptions }
744+ selectedValue = { selectedFileId }
745+ inputValue = { inputValue }
746+ onInputChange = { handleComboboxChange }
747+ onClear = { ( e ) => handleRemoveFile ( filesArray [ 0 ] , e ) }
748+ onOpenChange = { ( open ) => {
749+ if ( open ) void loadWorkspaceFiles ( )
750+ } }
751+ disabled = { disabled }
752+ isLoading = { loadingWorkspaceFiles }
753+ formatFileSize = { formatFileSize }
754+ truncateMiddle = { truncateMiddle }
755+ isDeleting = { deletingFiles [ filesArray [ 0 ] ?. path || '' ] }
756+ />
757+ ) }
758+
607759 { /* Show dropdown selector if no files and not uploading */ }
608760 { ! hasFiles && ! isUploading && (
609761 < Combobox
0 commit comments