@@ -20,11 +20,16 @@ import { AutoApproveDropdown } from "./AutoApproveDropdown"
2020import { StandardTooltip } from "../ui"
2121import { IndexingStatusBadge } from "./IndexingStatusBadge"
2222import { VolumeX } from "lucide-react"
23+ import { getIconForFilePath , getIconUrlByName } from "vscode-material-icons"
2324
2425import { MentionNode } from "./lexical/MentionNode"
2526import { LexicalMentionPlugin } from "./lexical/LexicalMentionPlugin"
27+ import { LexicalSelectAllPlugin } from "./lexical/LexicalSelectAllPlugin"
2628import ContextMenu from "./ContextMenu"
2729import { ContextMenuOptionType , getContextMenuOptions , SearchResult } from "@/utils/context-mentions"
30+ import Thumbnails from "../common/Thumbnails"
31+ import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
32+ import { removeLeadingNonAlphanumeric } from "@/utils/removeLeadingNonAlphanumeric"
2833
2934type ChatTextAreaProps = {
3035 inputValue : string
@@ -57,11 +62,11 @@ export const ChatLexicalTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaP
5762 setInputValue,
5863 selectApiConfigDisabled,
5964 placeholderText,
60- // selectedImages,
61- // setSelectedImages,
65+ selectedImages,
66+ setSelectedImages,
6267 // onSend,
6368 // onSelectImages,
64- // shouldDisableImages,
69+ shouldDisableImages,
6570 // onHeightChange,
6671 mode,
6772 setMode,
@@ -88,7 +93,6 @@ export const ChatLexicalTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaP
8893 } = useExtensionState ( )
8994
9095 const [ isFocused , setIsFocused ] = useState ( false )
91- const [ isDraggingOver , _setIsDraggingOver ] = useState ( false )
9296 const [ showContextMenu , setShowContextMenu ] = useState ( false )
9397 const [ searchQuery , setSearchQuery ] = useState ( "" )
9498 const [ selectedMenuIndex , setSelectedMenuIndex ] = useState ( - 1 )
@@ -121,13 +125,186 @@ export const ChatLexicalTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaP
121125 vscode . postMessage ( { type : "loadApiConfigurationById" , text : value } )
122126 } , [ ] )
123127
124- const [ isTtsPlaying , _setIsTtsPlaying ] = useState ( false )
128+ const [ isTtsPlaying , setIsTtsPlaying ] = useState ( false )
129+ const [ isDraggingOver , setIsDraggingOver ] = useState ( false )
130+ const [ materialIconsBaseUri , setMaterialIconsBaseUri ] = useState ( "" )
131+
132+ // Get the icons base uri on mount
133+ useEffect ( ( ) => {
134+ const w = window as any
135+ setMaterialIconsBaseUri ( w . MATERIAL_ICONS_BASE_URI )
136+ } , [ ] )
137+
138+ // Extract mentions from input value for context display - only after finishing mention
139+ const [ validMentions , setValidMentions ] = useState < string [ ] > ( [ ] )
140+
141+ // Update mentions only when they are complete (not live)
142+ useEffect ( ( ) => {
143+ const mentionRegex = / @ ( [ ^ @ \s ] + ) (? = \s | $ ) / g // Only match completed mentions (followed by space or end)
144+ const mentions = [ ]
145+ let match
146+ while ( ( match = mentionRegex . exec ( inputValue ) ) !== null ) {
147+ mentions . push ( match [ 1 ] )
148+ }
149+
150+ // Only update if mentions actually changed
151+ if ( JSON . stringify ( mentions ) !== JSON . stringify ( validMentions ) ) {
152+ setValidMentions ( mentions )
153+ }
154+ } , [ inputValue , validMentions ] )
155+
156+ // Smart filename disambiguation - like VSCode tabs
157+ const getDisplayName = useCallback ( ( mention : string , allMentions : string [ ] ) => {
158+ // Remove leading non-alphanumeric and trailing slash
159+ const path = removeLeadingNonAlphanumeric ( mention ) . replace ( / \/ $ / , "" )
160+ const pathList = path . split ( "/" )
161+ const filename = pathList . at ( - 1 ) || mention
162+
163+ // Check if there are other mentions with the same filename
164+ const sameFilenames = allMentions . filter ( ( m ) => {
165+ const otherPath = removeLeadingNonAlphanumeric ( m ) . replace ( / \/ $ / , "" )
166+ const otherFilename = otherPath . split ( "/" ) . at ( - 1 ) || m
167+ return otherFilename === filename && m !== mention
168+ } )
169+
170+ if ( sameFilenames . length === 0 ) {
171+ return filename // No conflicts, just show filename
172+ }
173+
174+ // There are conflicts, need to show directory to disambiguate
175+ if ( pathList . length > 1 ) {
176+ // Show filename with first directory
177+ return `${ pathList [ pathList . length - 2 ] } /${ filename } `
178+ }
179+
180+ return filename
181+ } , [ ] )
182+
183+ // Get material icon for mention
184+ const getMaterialIconForMention = useCallback (
185+ ( mention : string ) => {
186+ const name = mention . split ( "/" ) . filter ( Boolean ) . at ( - 1 ) ?? ""
187+ const iconName = getIconForFilePath ( name )
188+ return getIconUrlByName ( iconName , materialIconsBaseUri )
189+ } ,
190+ [ materialIconsBaseUri ] ,
191+ )
192+
193+ // Check if we should show the context bar
194+ const shouldShowContextBar = validMentions . length > 0 || selectedImages . length > 0
195+
196+ // Handle image pasting
197+ const handlePaste = useCallback (
198+ async ( e : React . ClipboardEvent ) => {
199+ const items = e . clipboardData . items
200+ const acceptedTypes = [ "png" , "jpeg" , "webp" ]
201+
202+ const imageItems = Array . from ( items ) . filter ( ( item ) => {
203+ const [ type , subtype ] = item . type . split ( "/" )
204+ return type === "image" && acceptedTypes . includes ( subtype )
205+ } )
206+
207+ if ( ! shouldDisableImages && imageItems . length > 0 ) {
208+ e . preventDefault ( )
209+
210+ const imagePromises = imageItems . map ( ( item ) => {
211+ return new Promise < string | null > ( ( resolve ) => {
212+ const blob = item . getAsFile ( )
213+
214+ if ( ! blob ) {
215+ resolve ( null )
216+ return
217+ }
218+
219+ const reader = new FileReader ( )
220+
221+ reader . onloadend = ( ) => {
222+ if ( reader . error ) {
223+ console . error ( t ( "chat:errorReadingFile" ) , reader . error )
224+ resolve ( null )
225+ } else {
226+ const result = reader . result
227+ resolve ( typeof result === "string" ? result : null )
228+ }
229+ }
230+
231+ reader . readAsDataURL ( blob )
232+ } )
233+ } )
234+
235+ const imageDataArray = await Promise . all ( imagePromises )
236+ const dataUrls = imageDataArray . filter ( ( dataUrl ) : dataUrl is string => dataUrl !== null )
237+
238+ if ( dataUrls . length > 0 ) {
239+ setSelectedImages ( ( prevImages ) => [ ...prevImages , ...dataUrls ] . slice ( 0 , MAX_IMAGES_PER_MESSAGE ) )
240+ } else {
241+ console . warn ( t ( "chat:noValidImages" ) )
242+ }
243+ }
244+ } ,
245+ [ shouldDisableImages , setSelectedImages , t ] ,
246+ )
247+
248+ // Handle drag and drop
249+ const handleDrop = useCallback (
250+ async ( e : React . DragEvent < HTMLDivElement > ) => {
251+ e . preventDefault ( )
252+ setIsDraggingOver ( false )
253+
254+ const files = Array . from ( e . dataTransfer . files )
255+
256+ if ( files . length > 0 ) {
257+ const acceptedTypes = [ "png" , "jpeg" , "webp" ]
258+
259+ const imageFiles = files . filter ( ( file ) => {
260+ const [ type , subtype ] = file . type . split ( "/" )
261+ return type === "image" && acceptedTypes . includes ( subtype )
262+ } )
263+
264+ if ( ! shouldDisableImages && imageFiles . length > 0 ) {
265+ const imagePromises = imageFiles . map ( ( file ) => {
266+ return new Promise < string | null > ( ( resolve ) => {
267+ const reader = new FileReader ( )
268+
269+ reader . onloadend = ( ) => {
270+ if ( reader . error ) {
271+ console . error ( t ( "chat:errorReadingFile" ) , reader . error )
272+ resolve ( null )
273+ } else {
274+ const result = reader . result
275+ resolve ( typeof result === "string" ? result : null )
276+ }
277+ }
278+
279+ reader . readAsDataURL ( file )
280+ } )
281+ } )
282+
283+ const imageDataArray = await Promise . all ( imagePromises )
284+ const dataUrls = imageDataArray . filter ( ( dataUrl ) : dataUrl is string => dataUrl !== null )
285+
286+ if ( dataUrls . length > 0 ) {
287+ setSelectedImages ( ( prevImages ) =>
288+ [ ...prevImages , ...dataUrls ] . slice ( 0 , MAX_IMAGES_PER_MESSAGE ) ,
289+ )
290+ } else {
291+ console . warn ( t ( "chat:noValidImages" ) )
292+ }
293+ }
294+ }
295+ } ,
296+ [ shouldDisableImages , setSelectedImages , t ] ,
297+ )
125298
126299 useEffect ( ( ) => {
127300 const messageHandler = ( event : MessageEvent ) => {
128301 const message = event . data
129302
130- if ( message . type === "commitSearchResults" ) {
303+ if ( message . type === "ttsStart" ) {
304+ setIsTtsPlaying ( true )
305+ } else if ( message . type === "ttsStop" ) {
306+ setIsTtsPlaying ( false )
307+ } else if ( message . type === "commitSearchResults" ) {
131308 const commits = message . commits . map ( ( commit : any ) => ( {
132309 type : ContextMenuOptionType . Git ,
133310 value : commit . hash ,
@@ -401,7 +578,75 @@ export const ChatLexicalTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaP
401578 "space-y-1 bg-editor-background outline-none border border-none box-border" ,
402579 isEditMode ? "p-2 w-full" : "relative px-1.5 pb-1 w-[calc(100%-16px)] ml-auto mr-auto" ,
403580 ) } >
404- < div className = "relative" >
581+ { /* Context Bar */ }
582+ { shouldShowContextBar && (
583+ < div className = "mb-2" >
584+ < div className = "flex items-center gap-1 p-2 bg-vscode-input-background border border-vscode-focusBorder rounded overflow-x-auto" >
585+ { /* Context mentions */ }
586+ { validMentions . map ( ( mention , index ) => {
587+ const displayName = getDisplayName ( mention , validMentions )
588+ const iconUrl = getMaterialIconForMention ( mention )
589+ return (
590+ < div
591+ key = { index }
592+ className = "flex items-center gap-1 px-2 py-1 bg-vscode-editor-background text-vscode-editor-foreground rounded text-xs whitespace-nowrap flex-shrink-0" >
593+ < img
594+ src = { iconUrl }
595+ alt = "File"
596+ style = { {
597+ width : "12px" ,
598+ height : "12px" ,
599+ flexShrink : 0 ,
600+ } }
601+ />
602+ < span > { displayName } </ span >
603+ </ div >
604+ )
605+ } ) }
606+
607+ { /* Images */ }
608+ { selectedImages . length > 0 && (
609+ < Thumbnails
610+ images = { selectedImages }
611+ setImages = { setSelectedImages }
612+ style = { {
613+ marginBottom : 0 ,
614+ display : "flex" ,
615+ gap : 4 ,
616+ } }
617+ />
618+ ) }
619+ </ div >
620+ </ div >
621+ ) }
622+
623+ < div
624+ className = "relative"
625+ onDrop = { handleDrop }
626+ onDragOver = { ( e ) => {
627+ // Only allowed to drop images/files on shift key pressed.
628+ if ( ! e . shiftKey ) {
629+ setIsDraggingOver ( false )
630+ return
631+ }
632+
633+ e . preventDefault ( )
634+ setIsDraggingOver ( true )
635+ e . dataTransfer . dropEffect = "copy"
636+ } }
637+ onDragLeave = { ( e ) => {
638+ e . preventDefault ( )
639+ const rect = e . currentTarget . getBoundingClientRect ( )
640+
641+ if (
642+ e . clientX <= rect . left ||
643+ e . clientX >= rect . right ||
644+ e . clientY <= rect . top ||
645+ e . clientY >= rect . bottom
646+ ) {
647+ setIsDraggingOver ( false )
648+ }
649+ } } >
405650 < LexicalComposer initialConfig = { initialConfig } >
406651 < PlainTextPlugin
407652 contentEditable = {
@@ -442,6 +687,7 @@ export const ChatLexicalTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaP
442687 ) }
443688 onFocus = { ( ) => setIsFocused ( true ) }
444689 onBlur = { ( ) => setIsFocused ( false ) }
690+ onPaste = { handlePaste }
445691 />
446692 }
447693 ErrorBoundary = { LexicalErrorBoundary }
@@ -453,6 +699,7 @@ export const ChatLexicalTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaP
453699 onMentionTrigger = { handleMentionTrigger }
454700 onMentionHide = { handleMentionHide }
455701 />
702+ < LexicalSelectAllPlugin />
456703 </ LexicalComposer >
457704
458705 { showContextMenu && (
0 commit comments