@@ -65,8 +65,16 @@ define(function (require, exports, module) {
6565 let _livePreviewDismissed = false ; // user dismissed live preview chip
6666 let $contextBar ; // DOM ref
6767
68+ // Image paste state
69+ let _attachedImages = [ ] ; // [{dataUrl, mediaType, base64Data}]
70+ const MAX_IMAGES = 10 ;
71+ const MAX_IMAGE_BASE64_SIZE = 200 * 1024 ; // ~200KB base64
72+ const ALLOWED_IMAGE_TYPES = [
73+ "image/png" , "image/jpeg" , "image/gif" , "image/webp" , "image/svg+xml"
74+ ] ;
75+
6876 // DOM references
69- let $panel , $messages , $status , $statusText , $textarea , $sendBtn , $stopBtn ;
77+ let $panel , $messages , $status , $statusText , $textarea , $sendBtn , $stopBtn , $imagePreview ;
7078
7179 // Live DOM query for $messages — the cached $messages reference can become stale
7280 // after SidebarTabs reparents the panel. Use this for any deferred operations
@@ -89,6 +97,7 @@ define(function (require, exports, module) {
8997 '<span class="ai-status-text">' + Strings . AI_CHAT_THINKING + '</span>' +
9098 '</div>' +
9199 '<div class="ai-chat-input-area">' +
100+ '<div class="ai-chat-image-preview"></div>' +
92101 '<div class="ai-chat-context-bar"></div>' +
93102 '<div class="ai-chat-input-wrap">' +
94103 '<textarea class="ai-chat-textarea" placeholder="' + Strings . AI_CHAT_PLACEHOLDER + '" rows="1"></textarea>' +
@@ -182,6 +191,7 @@ define(function (require, exports, module) {
182191 $textarea = $panel . find ( ".ai-chat-textarea" ) ;
183192 $sendBtn = $panel . find ( ".ai-send-btn" ) ;
184193 $stopBtn = $panel . find ( ".ai-stop-btn" ) ;
194+ $imagePreview = $panel . find ( ".ai-chat-image-preview" ) ;
185195
186196 // Event handlers
187197 $sendBtn . on ( "click" , _sendMessage ) ;
@@ -211,6 +221,43 @@ define(function (require, exports, module) {
211221 this . style . height = Math . min ( this . scrollHeight , 96 ) + "px" ; // max ~6rem
212222 } ) ;
213223
224+ // Paste handler for images
225+ $textarea . on ( "paste" , function ( e ) {
226+ const items = ( e . originalEvent || e ) . clipboardData && ( e . originalEvent || e ) . clipboardData . items ;
227+ if ( ! items ) {
228+ return ;
229+ }
230+ let imageFound = false ;
231+ for ( let i = 0 ; i < items . length ; i ++ ) {
232+ const item = items [ i ] ;
233+ if ( item . kind === "file" && ALLOWED_IMAGE_TYPES . indexOf ( item . type ) !== - 1 ) {
234+ if ( _attachedImages . length >= MAX_IMAGES ) {
235+ break ;
236+ }
237+ imageFound = true ;
238+ const blob = item . getAsFile ( ) ;
239+ const reader = new FileReader ( ) ;
240+ reader . onload = function ( ev ) {
241+ const dataUrl = ev . target . result ;
242+ const commaIdx = dataUrl . indexOf ( "," ) ;
243+ const base64Data = dataUrl . slice ( commaIdx + 1 ) ;
244+ if ( base64Data . length > MAX_IMAGE_BASE64_SIZE ) {
245+ // Resize oversized images via canvas
246+ _resizeImage ( dataUrl , function ( resized ) {
247+ _addImageIfUnique ( resized . dataUrl , resized . mediaType , resized . base64Data ) ;
248+ } ) ;
249+ } else {
250+ _addImageIfUnique ( dataUrl , item . type , base64Data ) ;
251+ }
252+ } ;
253+ reader . readAsDataURL ( blob ) ;
254+ }
255+ }
256+ if ( imageFound ) {
257+ e . preventDefault ( ) ;
258+ }
259+ } ) ;
260+
214261 // Track scroll position for auto-scroll
215262 $messages . on ( "scroll" , function ( ) {
216263 const el = $messages [ 0 ] ;
@@ -281,8 +328,7 @@ define(function (require, exports, module) {
281328 const $img = $ ( '<img class="ai-tool-screenshot" src="data:image/png;base64,' + base64 + '">' ) ;
282329 $img . on ( "click" , function ( e ) {
283330 e . stopPropagation ( ) ;
284- $img . toggleClass ( "expanded" ) ;
285- _scrollToBottom ( ) ;
331+ _showImageLightbox ( $img . attr ( "src" ) ) ;
286332 } ) ;
287333 $img . on ( "load" , function ( ) {
288334 // Force scroll — the image load changes height after insertion,
@@ -462,6 +508,104 @@ define(function (require, exports, module) {
462508 $contextBar . toggleClass ( "has-chips" , $contextBar . children ( ) . length > 0 ) ;
463509 }
464510
511+ /**
512+ * Add an image to _attachedImages if it's not a duplicate.
513+ */
514+ function _addImageIfUnique ( dataUrl , mediaType , base64Data ) {
515+ const isDuplicate = _attachedImages . some ( function ( existing ) {
516+ return existing . base64Data === base64Data ;
517+ } ) ;
518+ if ( ! isDuplicate && _attachedImages . length < MAX_IMAGES ) {
519+ _attachedImages . push ( { dataUrl : dataUrl , mediaType : mediaType , base64Data : base64Data } ) ;
520+ _renderImagePreview ( ) ;
521+ }
522+ }
523+
524+ /**
525+ * Resize an image so its base64 data stays under MAX_IMAGE_BASE64_SIZE.
526+ * Two-phase strategy using WebP for better quality-per-byte:
527+ * Phase 1 — reduce quality at original dimensions.
528+ * Phase 2 — scale dimensions down (75%, then 50%) and retry quality steps.
529+ */
530+ function _resizeImage ( dataUrl , callback ) {
531+ const img = new Image ( ) ;
532+ img . onload = function ( ) {
533+ const canvas = document . createElement ( "canvas" ) ;
534+ const ctx = canvas . getContext ( "2d" ) ;
535+ const qualitySteps = [ 0.92 , 0.85 , 0.75 , 0.6 , 0.45 ] ;
536+ const scaleSteps = [ 1 , 0.75 , 0.5 ] ;
537+ let result ;
538+
539+ for ( let s = 0 ; s < scaleSteps . length ; s ++ ) {
540+ const scale = scaleSteps [ s ] ;
541+ canvas . width = Math . round ( img . width * scale ) ;
542+ canvas . height = Math . round ( img . height * scale ) ;
543+ ctx . drawImage ( img , 0 , 0 , canvas . width , canvas . height ) ;
544+
545+ for ( let q = 0 ; q < qualitySteps . length ; q ++ ) {
546+ result = canvas . toDataURL ( "image/webp" , qualitySteps [ q ] ) ;
547+ if ( result . split ( "," ) [ 1 ] . length <= MAX_IMAGE_BASE64_SIZE ) {
548+ const base64 = result . split ( "," ) [ 1 ] ;
549+ callback ( { dataUrl : result , mediaType : "image/webp" , base64Data : base64 } ) ;
550+ return ;
551+ }
552+ }
553+ }
554+
555+ // Last resort: use the smallest result we got
556+ const base64 = result . split ( "," ) [ 1 ] ;
557+ callback ( { dataUrl : result , mediaType : "image/webp" , base64Data : base64 } ) ;
558+ } ;
559+ img . src = dataUrl ;
560+ }
561+
562+ /**
563+ * Show a lightbox overlay with the full-size image.
564+ */
565+ function _showImageLightbox ( src ) {
566+ const $overlay = $ (
567+ '<div class="ai-image-lightbox">' +
568+ '<img />' +
569+ '</div>'
570+ ) ;
571+ $overlay . find ( "img" ) . attr ( "src" , src ) ;
572+ $overlay . on ( "click" , function ( ) {
573+ $overlay . remove ( ) ;
574+ } ) ;
575+ $panel . append ( $overlay ) ;
576+ }
577+
578+ /**
579+ * Render the image preview strip from _attachedImages.
580+ */
581+ function _renderImagePreview ( ) {
582+ if ( ! $imagePreview ) {
583+ return ;
584+ }
585+ $imagePreview . empty ( ) ;
586+ if ( _attachedImages . length === 0 ) {
587+ $imagePreview . removeClass ( "has-images" ) ;
588+ return ;
589+ }
590+ $imagePreview . addClass ( "has-images" ) ;
591+ _attachedImages . forEach ( function ( img , idx ) {
592+ const $thumb = $ (
593+ '<span class="ai-image-thumb">' +
594+ '<img />' +
595+ '<button class="ai-image-remove" title="' + Strings . AI_CHAT_IMAGE_REMOVE + '">×</button>' +
596+ '</span>'
597+ ) ;
598+ $thumb . find ( "img" ) . attr ( "src" , img . dataUrl ) . on ( "click" , function ( ) {
599+ _showImageLightbox ( img . dataUrl ) ;
600+ } ) ;
601+ $thumb . find ( ".ai-image-remove" ) . on ( "click" , function ( ) {
602+ _attachedImages . splice ( idx , 1 ) ;
603+ _renderImagePreview ( ) ;
604+ } ) ;
605+ $imagePreview . append ( $thumb ) ;
606+ } ) ;
607+ }
608+
465609 /**
466610 * Send the current input as a message to Claude.
467611 */
@@ -474,8 +618,16 @@ define(function (require, exports, module) {
474618 // Show "+ New" button once a conversation starts
475619 $panel . find ( ".ai-new-session-btn" ) . show ( ) ;
476620
621+ // Capture attached images before clearing
622+ const imagesForDisplay = _attachedImages . slice ( ) ;
623+ const imagesPayload = _attachedImages . map ( function ( img ) {
624+ return { mediaType : img . mediaType , base64Data : img . base64Data } ;
625+ } ) ;
626+ _attachedImages = [ ] ;
627+ _renderImagePreview ( ) ;
628+
477629 // Append user message
478- _appendUserMessage ( text ) ;
630+ _appendUserMessage ( text , imagesForDisplay ) ;
479631
480632 // Clear input
481633 $textarea . val ( "" ) ;
@@ -540,7 +692,8 @@ define(function (require, exports, module) {
540692 projectPath : projectPath ,
541693 sessionAction : "continue" ,
542694 locale : brackets . getLocale ( ) ,
543- selectionContext : selectionContext
695+ selectionContext : selectionContext ,
696+ images : imagesPayload . length > 0 ? imagesPayload : undefined
544697 } ) . then ( function ( result ) {
545698 _currentRequestId = result . requestId ;
546699 console . log ( "[AI UI] RequestId:" , result . requestId ) ;
@@ -583,6 +736,8 @@ define(function (require, exports, module) {
583736 _cursorDismissed = false ;
584737 _cursorDismissedLine = null ;
585738 _livePreviewDismissed = false ;
739+ _attachedImages = [ ] ;
740+ _renderImagePreview ( ) ;
586741 SnapshotStore . reset ( ) ;
587742 PhoenixConnectors . clearPreviousContentMap ( ) ;
588743 if ( $messages ) {
@@ -1281,14 +1436,26 @@ define(function (require, exports, module) {
12811436
12821437 // --- DOM helpers ---
12831438
1284- function _appendUserMessage ( text ) {
1439+ function _appendUserMessage ( text , images ) {
12851440 const $msg = $ (
12861441 '<div class="ai-msg ai-msg-user">' +
12871442 '<div class="ai-msg-label">' + Strings . AI_CHAT_LABEL_YOU + '</div>' +
12881443 '<div class="ai-msg-content"></div>' +
12891444 '</div>'
12901445 ) ;
12911446 $msg . find ( ".ai-msg-content" ) . text ( text ) ;
1447+ if ( images && images . length > 0 ) {
1448+ const $imgDiv = $ ( '<div class="ai-user-images"></div>' ) ;
1449+ images . forEach ( function ( img ) {
1450+ const $thumb = $ ( '<img class="ai-user-image-thumb" />' ) ;
1451+ $thumb . attr ( "src" , img . dataUrl ) ;
1452+ $thumb . on ( "click" , function ( ) {
1453+ _showImageLightbox ( img . dataUrl ) ;
1454+ } ) ;
1455+ $imgDiv . append ( $thumb ) ;
1456+ } ) ;
1457+ $msg . find ( ".ai-msg-content" ) . append ( $imgDiv ) ;
1458+ }
12921459 $messages . append ( $msg ) ;
12931460 _scrollToBottom ( ) ;
12941461 }
0 commit comments