@@ -40,6 +40,7 @@ import { IModelDeltaDecoration } from '@theia/monaco-editor-core/esm/vs/editor/c
4040import { EditorOption } from '@theia/monaco-editor-core/esm/vs/editor/common/config/editorOptions' ;
4141import { ChatInputHistoryService , ChatInputNavigationState } from './chat-input-history' ;
4242import { ContextFileValidationService , FileValidationResult , FileValidationState } from '@theia/ai-chat/lib/browser/context-file-validation-service' ;
43+ import { PendingImageRegistry } from '@theia/ai-chat/lib/browser/pending-image-registry' ;
4344
4445type Query = ( query : string , mode ?: string ) => Promise < void > ;
4546type Unpin = ( ) => void ;
@@ -110,6 +111,9 @@ export class AIChatInputWidget extends ReactWidget {
110111 @inject ( ContextFileValidationService ) @optional ( )
111112 protected readonly validationService : ContextFileValidationService | undefined ;
112113
114+ @inject ( PendingImageRegistry )
115+ protected readonly pendingImageRegistry : PendingImageRegistry ;
116+
113117 protected fileValidationState = new Map < string , FileValidationResult > ( ) ;
114118
115119 @inject ( ContextKeyService )
@@ -220,9 +224,19 @@ export class AIChatInputWidget extends ReactWidget {
220224
221225 protected onDisposeForChatModel = new DisposableCollection ( ) ;
222226 protected _chatModel : ChatModel ;
227+
228+ /**
229+ * Disposables for pending image registrations, keyed by short ID.
230+ * These are cleared after the message is sent.
231+ */
232+ protected pendingImageDisposables = new Map < string , Disposable > ( ) ;
223233 set chatModel ( chatModel : ChatModel ) {
224234 this . onDisposeForChatModel . dispose ( ) ;
225235 this . onDisposeForChatModel = new DisposableCollection ( ) ;
236+ // Register mapping from editor URI to model ID for hover provider lookups
237+ this . onDisposeForChatModel . push (
238+ this . pendingImageRegistry . registerEditorMapping ( this . getResourceUri ( ) . toString ( ) , chatModel . id )
239+ ) ;
226240 this . onDisposeForChatModel . push ( chatModel . onDidChange ( event => {
227241 if ( event . kind === 'addVariable' ) {
228242 // Validate files added via any path (including LLM tool calls)
@@ -242,21 +256,7 @@ export class AIChatInputWidget extends ReactWidget {
242256 }
243257 } ) ;
244258 this . update ( ) ;
245- } else if ( event . kind === 'addRequest' ) {
246- // Only clear image context variables, preserve other context (e.g., attached files)
247- // Never clear on parse failure.
248- const variables = chatModel . context . getVariables ( ) ;
249- const imageIndices = variables
250- . map ( ( v , i ) => {
251- const origin = ImageContextVariable . getOriginSafe ( v ) ;
252- return origin === 'temporary' ? i : - 1 ;
253- } )
254- . filter ( i => i !== - 1 ) ;
255- if ( imageIndices . length > 0 ) {
256- chatModel . context . deleteVariables ( ...imageIndices ) ;
257- }
258- this . update ( ) ;
259- } else if ( event . kind === 'removeVariable' || event . kind === 'changeHierarchyBranch' ) {
259+ } else if ( event . kind === 'removeVariable' || event . kind === 'addRequest' || event . kind === 'changeHierarchyBranch' ) {
260260 this . update ( ) ;
261261 }
262262 } ) ) ;
@@ -561,38 +561,71 @@ export class AIChatInputWidget extends ReactWidget {
561561 const dataTransferText = event . dataTransfer ?. getData ( 'text/plain' ) ;
562562 const position = this . editorRef ?. getControl ( ) . getTargetAtClientPoint ( event . clientX , event . clientY ) ?. position ;
563563 this . variableService . getDropResult ( event . nativeEvent , { type : 'ai-chat-input-widget' } ) . then ( result => {
564- result . variables . forEach ( variable => this . addContext ( variable ) ) ;
565- const text = result . text ?? dataTransferText ;
566- if ( position && text ) {
564+ const textsToInsert : string [ ] = [ ] ;
565+
566+ result . variables . forEach ( variable => {
567+ if ( ImageContextVariable . isImageContextRequest ( variable ) ) {
568+ // Register with short ID and insert short reference at drop position
569+ const shortId = this . registerPendingImage ( variable ) ;
570+ textsToInsert . push ( `#${ variable . variable . name } :${ shortId } ` ) ;
571+ } else {
572+ this . addContext ( variable ) ;
573+ }
574+ } ) ;
575+
576+ // Add any text from drop result or data transfer
577+ if ( result . text ) {
578+ textsToInsert . push ( result . text ) ;
579+ } else if ( dataTransferText && textsToInsert . length === 0 ) {
580+ // Only use dataTransferText if we have nothing else to insert
581+ textsToInsert . push ( dataTransferText ) ;
582+ }
583+
584+ if ( position && textsToInsert . length > 0 ) {
567585 this . editorRef ?. getControl ( ) . executeEdits ( 'drag-and-drop' , [ {
568586 range : {
569587 startLineNumber : position . lineNumber ,
570588 startColumn : position . column ,
571589 endLineNumber : position . lineNumber ,
572590 endColumn : position . column
573591 } ,
574- text
592+ text : textsToInsert . join ( ' ' )
575593 } ] ) ;
576594 }
577595 } ) ;
578596 }
579597
580598 protected onPaste ( event : ClipboardEvent ) : void {
581599 this . variableService . getPasteResult ( event , { type : 'ai-chat-input-widget' } ) . then ( result => {
582- result . variables . forEach ( variable => this . addContext ( variable ) ) ;
583- if ( result . text ) {
584- const position = this . editorRef ?. getControl ( ) . getPosition ( ) ;
585- if ( position && result . text ) {
586- this . editorRef ?. getControl ( ) . executeEdits ( 'paste' , [ {
587- range : {
588- startLineNumber : position . lineNumber ,
589- startColumn : position . column ,
590- endLineNumber : position . lineNumber ,
591- endColumn : position . column
592- } ,
593- text : result . text
594- } ] ) ;
600+ const position = this . editorRef ?. getControl ( ) . getPosition ( ) ;
601+ const textsToInsert : string [ ] = [ ] ;
602+
603+ result . variables . forEach ( variable => {
604+ if ( ImageContextVariable . isImageContextRequest ( variable ) ) {
605+ // Register with short ID and insert short reference at cursor position
606+ const shortId = this . registerPendingImage ( variable ) ;
607+ textsToInsert . push ( `#${ variable . variable . name } :${ shortId } ` ) ;
608+ } else {
609+ this . addContext ( variable ) ;
595610 }
611+ } ) ;
612+
613+ // Insert any text from the paste result
614+ if ( result . text ) {
615+ textsToInsert . push ( result . text ) ;
616+ }
617+
618+ // Insert all collected text at cursor position
619+ if ( position && textsToInsert . length > 0 ) {
620+ this . editorRef ?. getControl ( ) . executeEdits ( 'paste' , [ {
621+ range : {
622+ startLineNumber : position . lineNumber ,
623+ startColumn : position . column ,
624+ endLineNumber : position . lineNumber ,
625+ endColumn : position . column
626+ } ,
627+ text : textsToInsert . join ( ' ' )
628+ } ] ) ;
596629 }
597630 } ) ;
598631 }
@@ -631,10 +664,6 @@ export class AIChatInputWidget extends ReactWidget {
631664 } ) ;
632665 }
633666
634- protected deleteContextElement ( index : number ) : void {
635- this . _chatModel . context . deleteVariables ( index ) ;
636- }
637-
638667 protected handleContextMenu ( event : IMouseEvent ) : void {
639668 this . contextMenuRenderer . render ( {
640669 menuPath : AIChatInputWidget . CONTEXT_MENU ,
@@ -650,9 +679,87 @@ export class AIChatInputWidget extends ReactWidget {
650679 this . _chatModel . context . addVariables ( variable ) ;
651680 }
652681
682+ /**
683+ * Get the scope URI used for registering pending images.
684+ * Uses the chat model ID for scoping, not the editor URI.
685+ */
686+ protected getScopeUri ( ) : string {
687+ const modelId = this . _chatModel ?. id ?? 'default' ;
688+ return this . pendingImageRegistry . getScopeUriForModel ( modelId ) ;
689+ }
690+
691+ /**
692+ * Register a pending image attachment with a short ID.
693+ * @returns The short ID that can be used in the text.
694+ */
695+ registerPendingImage ( variable : AIVariableResolutionRequest ) : string {
696+ const parsed = ImageContextVariable . parseRequest ( variable ) ;
697+ if ( ! parsed || ! variable . arg ) {
698+ throw new Error ( 'Invalid image context variable' ) ;
699+ }
700+
701+ const scopeUri = this . getScopeUri ( ) ;
702+ const baseName = this . getImageBaseName ( parsed ) ;
703+ const shortId = this . pendingImageRegistry . generateShortId ( baseName , scopeUri ) ;
704+
705+ const disposable = this . pendingImageRegistry . register (
706+ scopeUri ,
707+ shortId ,
708+ parsed ,
709+ variable . arg
710+ ) ;
711+ this . pendingImageDisposables . set ( shortId , disposable ) ;
712+
713+ return shortId ;
714+ }
715+
716+ /**
717+ * Clear all pending image attachments. Called after a message is sent.
718+ */
719+ clearPendingImageAttachments ( ) : void {
720+ // Dispose all registrations
721+ for ( const disposable of this . pendingImageDisposables . values ( ) ) {
722+ disposable . dispose ( ) ;
723+ }
724+ this . pendingImageDisposables . clear ( ) ;
725+
726+ // Also clear the scope in the registry (belt and suspenders)
727+ this . pendingImageRegistry . clearScope ( this . getScopeUri ( ) ) ;
728+ }
729+
730+ /**
731+ * Get a meaningful base name for an image variable.
732+ * For dropped files (with wsRelativePath), use the workspace-relative path (like file variables).
733+ * For pasted images, use "pasted_image".
734+ */
735+ protected getImageBaseName ( imageVariable : ImageContextVariable ) : string {
736+ // For file-based images, use the workspace-relative path (consistent with #file: variables)
737+ if ( imageVariable . wsRelativePath ) {
738+ return imageVariable . wsRelativePath ;
739+ }
740+
741+ // For pasted images, use a generic name
742+ return 'pasted_image' ;
743+ }
744+
745+ /**
746+ * Get all variables to be sent with the request.
747+ * Note: Image variables in text use short IDs that get resolved via the pending image registry.
748+ */
749+ getAllVariablesForRequest ( ) : AIVariableResolutionRequest [ ] {
750+ return [ ...this . _chatModel . context . getVariables ( ) ] ;
751+ }
752+
653753 protected getContext ( ) : readonly AIVariableResolutionRequest [ ] {
654754 return this . _chatModel . context . getVariables ( ) ;
655755 }
756+
757+ /**
758+ * Delete a context element by index.
759+ */
760+ protected deleteContextElement ( index : number ) : void {
761+ this . _chatModel . context . deleteVariables ( index ) ;
762+ }
656763}
657764
658765interface ChatInputProperties {
@@ -776,10 +883,10 @@ const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInpu
776883
777884 const editor = await props . editorProvider . createSimpleInline ( uri , editorContainerRef . current ! , {
778885 language : CHAT_VIEW_LANGUAGE_EXTENSION ,
779- // Disable code lens, inlay hints and hover support to avoid console errors from other contributions
886+ // Disable code lens and inlay hints to avoid console errors from other contributions
780887 codeLens : false ,
781888 inlayHints : { enabled : 'off' } ,
782- hover : { enabled : false } ,
889+ hover : { enabled : true } ,
783890 autoSizing : false , // we handle the sizing ourselves
784891 scrollBeyondLastLine : false ,
785892 scrollBeyondLastColumn : 0 ,
@@ -1475,33 +1582,29 @@ const ChatContext: React.FunctionComponent<ChatContextUI> = ({ context }) => (
14751582 < ul >
14761583 { context . map ( ( element , index ) => {
14771584 if ( ImageContextVariable . isImageContextRequest ( element . variable ) ) {
1478- let variable : ImageContextVariable | undefined ;
1479- try {
1480- variable = ImageContextVariable . parseRequest ( element . variable ) ;
1481- } catch {
1482- variable = undefined ;
1483- }
1484-
1585+ const variable = ImageContextVariable . parseRequest ( element . variable ) ;
1586+ const displayName = variable ?. name ?? variable ?. wsRelativePath ?. split ( '/' ) . pop ( ) ?? element . name ;
14851587 const title = variable ?. name ?? variable ?. wsRelativePath ?? element . details ?? element . name ;
1486- const label = variable ?. name ?? variable ?. wsRelativePath ?. split ( '/' ) . pop ( ) ?? element . name ;
1588+ // Check if we have the actual image data (pre-processed) or just a path reference
1589+ const hasImageData = variable && ImageContextVariable . isResolved ( variable ) ;
14871590
14881591 return < li key = { index } className = "theia-ChatInput-ChatContext-Element theia-ChatInput-ImageContext-Element"
14891592 title = { title } onClick = { ( ) => element . open ?.( ) } >
14901593 < div className = "theia-ChatInput-ChatContext-Row" >
14911594 < div className = { `theia-ChatInput-ChatContext-Icon ${ element . iconClass } ` } />
14921595 < div className = "theia-ChatInput-ChatContext-labelParts" >
14931596 < span className = { `theia-ChatInput-ChatContext-title ${ element . nameClass } ` } >
1494- { label }
1597+ { displayName }
14951598 </ span >
14961599 < span className = 'theia-ChatInput-ChatContext-additionalInfo' >
14971600 { element . additionalInfo }
14981601 </ span >
14991602 </ div >
15001603 < span className = "codicon codicon-close action" title = { nls . localizeByDefault ( 'Delete' ) } onClick = { e => { e . stopPropagation ( ) ; element . delete ( ) ; } } />
15011604 </ div >
1502- { variable && < div className = "theia-ChatInput-ChatContext-ImageRow" >
1605+ { hasImageData && < div className = "theia-ChatInput-ChatContext-ImageRow" >
15031606 < div className = 'theia-ChatInput-ImagePreview-Item' >
1504- < img src = { `data:${ variable . mimeType } ;base64,${ variable . data } ` } alt = { variable . name ?? label } />
1607+ < img src = { `data:${ variable . mimeType } ;base64,${ variable . data } ` } alt = { variable . name ?? displayName } />
15051608 </ div >
15061609 </ div > }
15071610 </ li > ;
0 commit comments