@@ -64,7 +64,7 @@ import {
6464} from "../../promptStashStore" ;
6565import { ComposerStashBadge } from "./ComposerStashBadge" ;
6666import { ComposerStashMenu } from "./ComposerStashMenu" ;
67- import { compressImageForStash } from "../../lib/stashImageCompression " ;
67+ import { compressImageForStash , compressImageToByteLimit } from "../../lib/imageCompression " ;
6868import { isCommandPaletteOpen } from "../../commandPaletteBus" ;
6969import { getTerminalFocusOwner } from "../../lib/terminalFocus" ;
7070import { resolveShortcutCommand } from "../../keybindings" ;
@@ -196,8 +196,6 @@ import { searchProviderSkills } from "../../providerSkillSearch";
196196import { useMediaQuery } from "../../hooks/useMediaQuery" ;
197197import type { ReviewCommentContext } from "../../reviewCommentContext" ;
198198
199- const IMAGE_SIZE_LIMIT_LABEL = `${ Math . round ( PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / ( 1024 * 1024 ) ) } MB` ;
200-
201199const COMPOSER_FLOATING_LAYER_SELECTOR = [
202200 '[data-slot="popover-popup"]' ,
203201 '[data-slot="menu-popup"]' ,
@@ -972,6 +970,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
972970 * thread) can still be stashed while an earlier encode is running.
973971 */
974972 const stashInFlightRef = useRef < Set < string > > ( new Set ( ) ) ;
973+ /**
974+ * Count of pasted images still being compressed, per thread. Reserved
975+ * against the attachment limit so concurrent pastes can't overshoot it,
976+ * and checked by `submitComposer` so a send can't race an image into the
977+ * next draft.
978+ */
979+ const pendingImageCompressionsRef = useRef < Map < ThreadId , number > > ( new Map ( ) ) ;
975980
976981 // ------------------------------------------------------------------
977982 // Derived: composer send state
@@ -1787,12 +1792,26 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
17871792 event ?. preventDefault ( ) ;
17881793 return ;
17891794 }
1795+ // A send while a pasted image is still compressing would strand that
1796+ // image: the turn snapshot wouldn't include it, and it would surface
1797+ // in the *next* draft instead. Only oversized images hit this — small
1798+ // files clear the pending counter within a microtask.
1799+ if ( activeThreadId && ( pendingImageCompressionsRef . current . get ( activeThreadId ) ?? 0 ) > 0 ) {
1800+ event ?. preventDefault ( ) ;
1801+ toastManager . add ( {
1802+ type : "info" ,
1803+ title : "Still compressing a pasted image." ,
1804+ description : "Send again once its thumbnail appears." ,
1805+ } ) ;
1806+ return ;
1807+ }
17901808 onSend ( event ) ;
17911809 if ( shouldBlurMobileComposerOnSubmit ( ) ) {
17921810 blurMobileComposerAfterSend ( ) ;
17931811 }
17941812 } ,
17951813 [
1814+ activeThreadId ,
17961815 blurMobileComposerAfterSend ,
17971816 isSendDisabled ,
17981817 noProviderAvailable ,
@@ -2239,7 +2258,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
22392258 // ------------------------------------------------------------------
22402259 // Callbacks: images
22412260 // ------------------------------------------------------------------
2242- const addComposerImages = ( files : File [ ] ) => {
2261+ const addComposerImages = async ( files : File [ ] ) => {
22432262 if ( ! activeThreadId || files . length === 0 ) return ;
22442263 if ( pendingUserInputs . length > 0 ) {
22452264 toastManager . add ( {
@@ -2248,40 +2267,81 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
22482267 } ) ;
22492268 return ;
22502269 }
2251- const nextImages : ComposerImageAttachment [ ] = [ ] ;
2252- let nextImageCount = composerImagesRef . current . length ;
2270+ // Captured before the awaits below: the user may switch threads while a
2271+ // large image is being compressed, and the attachments and errors belong
2272+ // to the thread the paste happened in.
2273+ const threadId = activeThreadId ;
2274+
2275+ // Validation happens synchronously so concurrent pastes see each other:
2276+ // accepted files reserve their attachment slots (via the pending counter)
2277+ // before the first await, keeping the total under the limit.
2278+ const pendingCount = pendingImageCompressionsRef . current . get ( threadId ) ?? 0 ;
2279+ let reservedCount = composerImagesRef . current . length + pendingCount ;
2280+ const acceptedFiles : File [ ] = [ ] ;
22532281 let error : string | null = null ;
22542282 for ( const file of files ) {
22552283 if ( ! file . type . startsWith ( "image/" ) ) {
22562284 error = `Unsupported file type for '${ file . name } '. Please attach image files only.` ;
22572285 continue ;
22582286 }
2259- if ( file . size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES ) {
2260- error = `'${ file . name } ' exceeds the ${ IMAGE_SIZE_LIMIT_LABEL } attachment limit.` ;
2261- continue ;
2262- }
2263- if ( nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS ) {
2287+ if ( reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS ) {
22642288 error = `You can attach up to ${ PROVIDER_SEND_TURN_MAX_ATTACHMENTS } images per message.` ;
22652289 break ;
22662290 }
2267- const previewUrl = URL . createObjectURL ( file ) ;
2268- nextImages . push ( {
2269- type : "image" ,
2270- id : randomUUID ( ) ,
2271- name : file . name || "image" ,
2272- mimeType : file . type ,
2273- sizeBytes : file . size ,
2274- previewUrl,
2275- file,
2276- } ) ;
2277- nextImageCount += 1 ;
2291+ acceptedFiles . push ( file ) ;
2292+ reservedCount += 1 ;
22782293 }
2279- if ( nextImages . length === 1 && nextImages [ 0 ] ) {
2280- addComposerImage ( nextImages [ 0 ] ) ;
2281- } else if ( nextImages . length > 1 ) {
2282- addComposerImagesToDraft ( nextImages ) ;
2294+ setThreadError ( threadId , error ) ;
2295+ if ( acceptedFiles . length === 0 ) return ;
2296+
2297+ pendingImageCompressionsRef . current . set ( threadId , pendingCount + acceptedFiles . length ) ;
2298+ try {
2299+ const nextImages : ComposerImageAttachment [ ] = [ ] ;
2300+ let compressionError : string | null = null ;
2301+ for ( const file of acceptedFiles ) {
2302+ // Images over the wire cap are downscaled to fit rather than
2303+ // refused; files already within it pass through byte-for-byte.
2304+ const compressed = await compressImageToByteLimit ( file , PROVIDER_SEND_TURN_MAX_IMAGE_BYTES ) ;
2305+ if ( ! compressed . ok ) {
2306+ compressionError =
2307+ compressed . reason === "unreadable"
2308+ ? `'${ file . name } ' could not be read as an image.`
2309+ : `'${ file . name } ' is too large to attach, even after compression.` ;
2310+ continue ;
2311+ }
2312+ const attachmentFile = compressed . file ;
2313+ const previewUrl = URL . createObjectURL ( attachmentFile ) ;
2314+ nextImages . push ( {
2315+ type : "image" ,
2316+ id : randomUUID ( ) ,
2317+ name : attachmentFile . name || "image" ,
2318+ mimeType : attachmentFile . type ,
2319+ sizeBytes : attachmentFile . size ,
2320+ previewUrl,
2321+ file : attachmentFile ,
2322+ } ) ;
2323+ }
2324+ if ( nextImages . length === 1 && nextImages [ 0 ] ) {
2325+ addComposerImage ( nextImages [ 0 ] ) ;
2326+ } else if ( nextImages . length > 1 ) {
2327+ addComposerImagesToDraft ( nextImages ) ;
2328+ }
2329+ // Only failures are reported here. Success must not pass `null`: by
2330+ // now other work (a failed send, an overlapping paste) may have set a
2331+ // thread error this call knows nothing about, and clearing it would
2332+ // swallow that message.
2333+ if ( compressionError !== null ) {
2334+ setThreadError ( threadId , compressionError ) ;
2335+ }
2336+ } finally {
2337+ const remaining =
2338+ ( pendingImageCompressionsRef . current . get ( threadId ) ?? 0 ) - acceptedFiles . length ;
2339+ if ( remaining > 0 ) {
2340+ pendingImageCompressionsRef . current . set ( threadId , remaining ) ;
2341+ } else {
2342+ pendingImageCompressionsRef . current . delete ( threadId ) ;
2343+ }
22832344 }
2284- setThreadError ( activeThreadId , error ) ;
22852345 } ;
22862346
22872347 const removeComposerImage = ( imageId : string ) => {
@@ -2297,7 +2357,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
22972357 const imageFiles = files . filter ( ( file ) => file . type . startsWith ( "image/" ) ) ;
22982358 if ( imageFiles . length === 0 ) return ;
22992359 event . preventDefault ( ) ;
2300- addComposerImages ( imageFiles ) ;
2360+ void addComposerImages ( imageFiles ) ;
23012361 } ;
23022362
23032363 const onComposerDragEnter = ( event : React . DragEvent < HTMLDivElement > ) => {
@@ -2331,7 +2391,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
23312391 dragDepthRef . current = 0 ;
23322392 setIsDragOverComposer ( false ) ;
23332393 const files = Array . from ( event . dataTransfer . files ) ;
2334- addComposerImages ( files ) ;
2394+ void addComposerImages ( files ) ;
23352395 focusComposer ( ) ;
23362396 } ;
23372397
0 commit comments