@@ -123,6 +123,14 @@ export type AIChatState = {
123123 * this value (seeding its editable content) and then clears it back to an empty string.
124124 */
125125 draft : string ;
126+
127+ /**
128+ * Follow-ups the visitor submitted while a previous turn was still streaming. They are held
129+ * here and sent automatically, one at a time in submission order, as each answer finishes
130+ * (see the flush in `streamResponse`), so submitting mid-stream isn't lost. Empty when
131+ * nothing is queued.
132+ */
133+ queuedMessages : string [ ] ;
126134} ;
127135
128136export type AIChatEvent =
@@ -158,6 +166,8 @@ export type AIChatController = {
158166 focus : ( ) => void ;
159167 /** Pre-fill the chat input with draft text, without sending it. */
160168 setDraft : ( draft : string ) => void ;
169+ /** Remove a follow-up queued to send after the current answer finishes, by its queue index. */
170+ cancelQueuedMessage : ( index : number ) => void ;
161171 /** Register an event listener */
162172 on : < T extends AIChatEvent [ 'type' ] > (
163173 event : T ,
@@ -182,6 +192,7 @@ const globalState = zustand.create<AIChatState>(() => {
182192 initialQuery : null ,
183193 references : [ ] ,
184194 draft : '' ,
195+ queuedMessages : [ ] ,
185196 } ;
186197} ) ;
187198
@@ -257,6 +268,9 @@ export function AIChatProvider(props: {
257268 notify ( eventsRef . current . get ( 'close' ) , { } ) ;
258269 } , [ setSearchState ] ) ;
259270
271+ // Lets `streamResponse` flush a queued follow-up via `onPostMessage`, which is defined later.
272+ const postMessageRef = React . useRef < ( ( input : { message : string } ) => void ) | null > ( null ) ;
273+
260274 // Stream a message with the AI backend
261275 const streamResponse = React . useCallback (
262276 async ( input : {
@@ -512,6 +526,15 @@ export function AIChatProvider(props: {
512526 loading : false ,
513527 error : false ,
514528 } ) ) ;
529+
530+ // Turn settled: send the next queued follow-up (oldest first). Held back while a
531+ // control is pending, since posting would throw; it flushes after that resolves.
532+ const { queuedMessages, control : activeControl } = globalState . getState ( ) ;
533+ const [ next , ...rest ] = queuedMessages ;
534+ if ( next !== undefined && ! activeControl ) {
535+ globalState . setState ( ( state ) => ( { ...state , queuedMessages : rest } ) ) ;
536+ postMessageRef . current ?.( { message : next } ) ;
537+ }
515538 }
516539 } catch ( error ) {
517540 console . error ( 'Error streaming AI response' , error ) ;
@@ -545,8 +568,12 @@ export function AIChatProvider(props: {
545568 throw new Error ( "We can't post a message when a control is active" ) ;
546569 }
547570
548- // Ignore duplicates while a previous turn is still streaming
571+ // Still streaming: queue this follow-up instead of dropping it (flushed in order in `streamResponse`).
549572 if ( responding ) {
573+ globalState . setState ( ( state ) => ( {
574+ ...state ,
575+ queuedMessages : [ ...state . queuedMessages , input . message ] ,
576+ } ) ) ;
550577 return ;
551578 }
552579
@@ -607,6 +634,21 @@ export function AIChatProvider(props: {
607634 [ setSearchState , siteSpaceId , trackEvent , streamResponse ]
608635 ) ;
609636
637+ // Keep the ref current so `streamResponse` can flush a queued follow-up via the latest callback.
638+ postMessageRef . current = onPostMessage ;
639+
640+ // Remove a follow-up queued while the assistant is still answering (the × on the affordance).
641+ const onCancelQueuedMessage = React . useCallback ( ( index : number ) => {
642+ globalState . setState ( ( state ) =>
643+ index < 0 || index >= state . queuedMessages . length
644+ ? state
645+ : {
646+ ...state ,
647+ queuedMessages : state . queuedMessages . filter ( ( _ , i ) => i !== index ) ,
648+ }
649+ ) ;
650+ } , [ ] ) ;
651+
610652 // Clear the conversation and reset ask parameter
611653 const onClear = React . useCallback ( ( ) => {
612654 globalState . setState ( ( state ) => ( {
@@ -621,6 +663,7 @@ export function AIChatProvider(props: {
621663 error : false ,
622664 initialQuery : null ,
623665 references : [ ] ,
666+ queuedMessages : [ ] ,
624667 } ) ) ;
625668
626669 // Reset ask parameter to empty string (keeps chat open but clears content)
@@ -704,6 +747,7 @@ export function AIChatProvider(props: {
704747 clearReferences : onClearReferences ,
705748 focus : onFocus ,
706749 setDraft : onSetDraft ,
750+ cancelQueuedMessage : onCancelQueuedMessage ,
707751 on : onEvent ,
708752 } ;
709753 } , [
@@ -716,6 +760,7 @@ export function AIChatProvider(props: {
716760 onClearReferences ,
717761 onFocus ,
718762 onSetDraft ,
763+ onCancelQueuedMessage ,
719764 onEvent ,
720765 ] ) ;
721766
0 commit comments