@@ -179,6 +179,7 @@ import {
179179 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY ,
180180 LastInvokedScriptByProjectSchema ,
181181 PullRequestDialogState ,
182+ QueuedMessage ,
182183 readFileAsDataUrl ,
183184 revokeBlobPreviewUrl ,
184185 revokeUserMessagePreviewUrls ,
@@ -347,6 +348,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
347348 const [ optimisticUserMessages , setOptimisticUserMessages ] = useState < ChatMessage [ ] > ( [ ] ) ;
348349 const optimisticUserMessagesRef = useRef ( optimisticUserMessages ) ;
349350 optimisticUserMessagesRef . current = optimisticUserMessages ;
351+ const [ queuedMessages , setQueuedMessages ] = useState < QueuedMessage [ ] > ( [ ] ) ;
352+ const queuedMessagesRef = useRef ( queuedMessages ) ;
353+ queuedMessagesRef . current = queuedMessages ;
350354 const composerTerminalContextsRef = useRef < TerminalContextDraft [ ] > ( composerTerminalContexts ) ;
351355 const [ localDraftErrorsByThreadId , setLocalDraftErrorsByThreadId ] = useState <
352356 Record < ThreadId , string | null >
@@ -1874,6 +1878,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
18741878 scheduleStickToBottom ( ) ;
18751879 } , [ phase , scheduleStickToBottom , timelineEntries ] ) ;
18761880
1881+ // Aggressively scroll to bottom after the user submits a new message.
1882+ // The virtualizer may not have settled by the time the first scroll fires,
1883+ // so we schedule multiple backup attempts similar to the thread-change handler.
1884+ const optimisticUserMessageCount = optimisticUserMessages . length ;
1885+ useEffect ( ( ) => {
1886+ if ( optimisticUserMessageCount === 0 ) return ;
1887+ shouldAutoScrollRef . current = true ;
1888+ setShowScrollToBottom ( false ) ;
1889+ forceStickToBottom ( ) ;
1890+ const t1 = window . setTimeout ( ( ) => {
1891+ forceStickToBottom ( ) ;
1892+ } , 50 ) ;
1893+ const t2 = window . setTimeout ( ( ) => {
1894+ forceStickToBottom ( ) ;
1895+ } , 150 ) ;
1896+ return ( ) => {
1897+ window . clearTimeout ( t1 ) ;
1898+ window . clearTimeout ( t2 ) ;
1899+ } ;
1900+ } , [ optimisticUserMessageCount , forceStickToBottom ] ) ;
1901+
18771902 useEffect ( ( ) => {
18781903 setExpandedWorkGroups ( { } ) ;
18791904 setPullRequestDialogState ( null ) ;
@@ -1960,6 +1985,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
19601985 }
19611986 return [ ] ;
19621987 } ) ;
1988+ setQueuedMessages ( [ ] ) ;
19631989 setSendPhase ( "idle" ) ;
19641990 setSendStartedAt ( null ) ;
19651991 setComposerHighlightedItemId ( null ) ;
@@ -2132,6 +2158,99 @@ export default function ChatView({ threadId }: ChatViewProps) {
21322158 sendPhase ,
21332159 ] ) ;
21342160
2161+ // ── Queue drain: dispatch next queued message when the current turn settles ──
2162+ const isDrainingQueueRef = useRef ( false ) ;
2163+ useEffect ( ( ) => {
2164+ if ( ! latestTurnSettled || queuedMessages . length === 0 ) return ;
2165+ if ( isSendBusy || isConnecting || sendInFlightRef . current || isDrainingQueueRef . current ) return ;
2166+ if ( ! activeThread || ! activeProject ) return ;
2167+ const api = readNativeApi ( ) ;
2168+ if ( ! api ) return ;
2169+
2170+ const [ nextQueued , ...rest ] = queuedMessages ;
2171+ if ( ! nextQueued ) return ;
2172+
2173+ isDrainingQueueRef . current = true ;
2174+ setQueuedMessages ( rest ) ;
2175+
2176+ // Mark the optimistic message as no longer queued
2177+ setOptimisticUserMessages ( ( existing ) =>
2178+ existing . map ( ( msg ) => ( msg . id === nextQueued . id ? { ...msg , queued : false } : msg ) ) ,
2179+ ) ;
2180+
2181+ const threadIdForSend = activeThread . id ;
2182+ const messageIdForSend = nextQueued . id ;
2183+ const messageCreatedAt = new Date ( ) . toISOString ( ) ;
2184+ const composerTerminalContextsSnapshot = nextQueued . terminalContexts ;
2185+ const messageTextForSend = appendTerminalContextsToPrompt (
2186+ nextQueued . text ,
2187+ composerTerminalContextsSnapshot ,
2188+ ) ;
2189+ const outgoingMessageText = formatOutgoingPrompt ( {
2190+ provider : selectedProvider ,
2191+ effort : selectedPromptEffort ,
2192+ text : messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT ,
2193+ } ) ;
2194+
2195+ sendInFlightRef . current = true ;
2196+ beginSendPhase ( "sending-turn" ) ;
2197+
2198+ ( async ( ) => {
2199+ await persistThreadSettingsForNextTurn ( {
2200+ threadId : threadIdForSend ,
2201+ createdAt : messageCreatedAt ,
2202+ ...( selectedModel ? { model : selectedModel } : { } ) ,
2203+ runtimeMode,
2204+ interactionMode,
2205+ } ) ;
2206+ const turnAttachments = await Promise . all (
2207+ nextQueued . images . map ( async ( image ) => ( {
2208+ type : "image" as const ,
2209+ name : image . name ,
2210+ mimeType : image . mimeType ,
2211+ sizeBytes : image . sizeBytes ,
2212+ dataUrl : await readFileAsDataUrl ( image . file ) ,
2213+ } ) ) ,
2214+ ) ;
2215+ await api . orchestration . dispatchCommand ( {
2216+ type : "thread.turn.start" ,
2217+ commandId : newCommandId ( ) ,
2218+ threadId : threadIdForSend ,
2219+ message : {
2220+ messageId : messageIdForSend ,
2221+ role : "user" ,
2222+ text : outgoingMessageText ,
2223+ attachments : turnAttachments ,
2224+ } ,
2225+ model : selectedModel || undefined ,
2226+ ...( selectedModelOptionsForDispatch
2227+ ? { modelOptions : selectedModelOptionsForDispatch }
2228+ : { } ) ,
2229+ ...( providerOptionsForDispatch ? { providerOptions : providerOptionsForDispatch } : { } ) ,
2230+ provider : selectedProvider ,
2231+ assistantDeliveryMode : settings . enableAssistantStreaming ? "streaming" : "buffered" ,
2232+ runtimeMode,
2233+ interactionMode,
2234+ createdAt : messageCreatedAt ,
2235+ } ) ;
2236+ } ) ( )
2237+ . catch ( ( err : unknown ) => {
2238+ setOptimisticUserMessages ( ( existing ) =>
2239+ existing . filter ( ( msg ) => msg . id !== nextQueued . id ) ,
2240+ ) ;
2241+ setThreadError (
2242+ threadIdForSend ,
2243+ err instanceof Error ? err . message : "Failed to send queued message." ,
2244+ ) ;
2245+ resetSendPhase ( ) ;
2246+ } )
2247+ . finally ( ( ) => {
2248+ sendInFlightRef . current = false ;
2249+ isDrainingQueueRef . current = false ;
2250+ } ) ;
2251+ // eslint-disable-next-line react-hooks/exhaustive-deps
2252+ } , [ latestTurnSettled , queuedMessages . length , isSendBusy , isConnecting ] ) ;
2253+
21352254 useEffect ( ( ) => {
21362255 if ( ! activeThreadId ) return ;
21372256 const previous = terminalOpenByThreadRef . current [ activeThreadId ] ?? false ;
@@ -2461,6 +2580,72 @@ export default function ChatView({ threadId }: ChatViewProps) {
24612580 }
24622581 return ;
24632582 }
2583+
2584+ // ── Queue message if a turn is already running ────────────────────
2585+ if ( phase === "running" ) {
2586+ const composerImagesSnapshot = [ ...composerImages ] ;
2587+ const messageTextForSend = appendTerminalContextsToPrompt (
2588+ promptForSend ,
2589+ sendableComposerTerminalContexts ,
2590+ ) ;
2591+ const messageCreatedAt = new Date ( ) . toISOString ( ) ;
2592+ const outgoingMessageText = formatOutgoingPrompt ( {
2593+ provider : selectedProvider ,
2594+ effort : selectedPromptEffort ,
2595+ text : messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT ,
2596+ } ) ;
2597+ const optimisticAttachments = composerImagesSnapshot . map ( ( image ) => ( {
2598+ type : "image" as const ,
2599+ id : image . id ,
2600+ name : image . name ,
2601+ mimeType : image . mimeType ,
2602+ sizeBytes : image . sizeBytes ,
2603+ previewUrl : image . previewUrl ,
2604+ } ) ) ;
2605+ const queuedId = newMessageId ( ) ;
2606+ setQueuedMessages ( ( existing ) => [
2607+ ...existing ,
2608+ {
2609+ id : queuedId ,
2610+ text : promptForSend ,
2611+ images : composerImagesSnapshot ,
2612+ terminalContexts : [ ...sendableComposerTerminalContexts ] ,
2613+ createdAt : messageCreatedAt ,
2614+ } ,
2615+ ] ) ;
2616+ setOptimisticUserMessages ( ( existing ) => [
2617+ ...existing ,
2618+ {
2619+ id : queuedId ,
2620+ role : "user" ,
2621+ text : outgoingMessageText ,
2622+ ...( optimisticAttachments . length > 0 ? { attachments : optimisticAttachments } : { } ) ,
2623+ createdAt : messageCreatedAt ,
2624+ streaming : false ,
2625+ queued : true ,
2626+ } ,
2627+ ] ) ;
2628+ shouldAutoScrollRef . current = true ;
2629+ forceStickToBottom ( ) ;
2630+ promptRef . current = "" ;
2631+ clearComposerDraftContent ( activeThread . id ) ;
2632+ setComposerHighlightedItemId ( null ) ;
2633+ setComposerCursor ( 0 ) ;
2634+ setComposerTrigger ( null ) ;
2635+ if ( expiredTerminalContextCount > 0 ) {
2636+ const toastCopy = buildExpiredTerminalContextToastCopy (
2637+ expiredTerminalContextCount ,
2638+ "omitted" ,
2639+ ) ;
2640+ toastManager . add ( {
2641+ type : "warning" ,
2642+ title : toastCopy . title ,
2643+ description : toastCopy . description ,
2644+ } ) ;
2645+ }
2646+ return ;
2647+ }
2648+
24642649 if ( ! activeProject ) return ;
24652650 const threadIdForSend = activeThread . id ;
24662651 const isFirstMessage = ! isServerThread || activeThread . messages . length === 0 ;
@@ -4158,6 +4343,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
41584343 Preparing worktree...
41594344 </ span >
41604345 ) : null }
4346+ { queuedMessages . length > 0 && phase === "running" ? (
4347+ < span className = "text-muted-foreground/60 text-xs" >
4348+ { queuedMessages . length } queued
4349+ </ span >
4350+ ) : null }
41614351 { activePendingProgress ? (
41624352 < div className = "flex items-center gap-2" >
41634353 { activePendingProgress . questionIndex > 0 ? (
@@ -4190,22 +4380,51 @@ export default function ChatView({ threadId }: ChatViewProps) {
41904380 </ Button >
41914381 </ div >
41924382 ) : phase === "running" ? (
4193- < button
4194- type = "button"
4195- className = "flex size-8 cursor-pointer items-center justify-center rounded-full bg-rose-500/90 text-white transition-all duration-150 hover:bg-rose-500 hover:scale-105 sm:h-8 sm:w-8"
4196- onClick = { ( ) => void onInterrupt ( ) }
4197- aria-label = "Stop generation"
4198- >
4199- < svg
4200- width = "12"
4201- height = "12"
4202- viewBox = "0 0 12 12"
4203- fill = "currentColor"
4204- aria-hidden = "true"
4383+ < div className = "flex items-center gap-1.5" >
4384+ < button
4385+ type = "button"
4386+ className = "flex size-8 cursor-pointer items-center justify-center rounded-full bg-rose-500/90 text-white transition-all duration-150 hover:bg-rose-500 hover:scale-105 sm:h-8 sm:w-8"
4387+ onClick = { ( ) => void onInterrupt ( ) }
4388+ aria-label = "Stop generation"
4389+ >
4390+ < svg
4391+ width = "12"
4392+ height = "12"
4393+ viewBox = "0 0 12 12"
4394+ fill = "currentColor"
4395+ aria-hidden = "true"
4396+ >
4397+ < rect x = "2" y = "2" width = "8" height = "8" rx = "1.5" />
4398+ </ svg >
4399+ </ button >
4400+ < button
4401+ type = "submit"
4402+ className = "flex h-9 w-9 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8"
4403+ disabled = {
4404+ isSendBusy ||
4405+ isConnecting ||
4406+ ! composerSendState . hasSendableContent
4407+ }
4408+ aria-label = "Queue message"
4409+ title = "Queue message (will send after current turn completes)"
42054410 >
4206- < rect x = "2" y = "2" width = "8" height = "8" rx = "1.5" />
4207- </ svg >
4208- </ button >
4411+ < svg
4412+ width = "14"
4413+ height = "14"
4414+ viewBox = "0 0 14 14"
4415+ fill = "none"
4416+ aria-hidden = "true"
4417+ >
4418+ < path
4419+ d = "M7 11.5V2.5M7 2.5L3 6.5M7 2.5L11 6.5"
4420+ stroke = "currentColor"
4421+ strokeWidth = "1.8"
4422+ strokeLinecap = "round"
4423+ strokeLinejoin = "round"
4424+ />
4425+ </ svg >
4426+ </ button >
4427+ </ div >
42094428 ) : pendingUserInputs . length === 0 ? (
42104429 showPlanFollowUpPrompt ? (
42114430 prompt . trim ( ) . length > 0 ? (
0 commit comments