@@ -205,13 +205,24 @@ interface FinalizeOptions {
205205 streamTerminal ?: boolean
206206}
207207
208+ /**
209+ * A send handed back to the caller instead of rendered. `userMessageId` is what
210+ * a retry reuses so the server deduplicates the two attempts. An `unreachable`
211+ * send is held in the queue until the browser is back online or the user sends
212+ * it: dispatching it again at once would fail the same way.
213+ */
214+ interface WithdrawnSendResult {
215+ userMessageId : string
216+ unreachable ?: boolean
217+ }
218+
208219/**
209220 * `true` when the send owns the transcript (rendered, or handed to reconnect),
210221 * `false` when the caller should restore the queue entry, and the object form
211- * when an unmount cleanup withdrew it — `userMessageId` is what a retry reuses
212- * so the server deduplicates the two attempts .
222+ * when the send was withdrawn: by an unmount cleanup, because it never reached
223+ * the server, or because another turn held the chat .
213224 */
214- type StartSendMessageResult = boolean | { userMessageId : string }
225+ type StartSendMessageResult = boolean | WithdrawnSendResult
215226
216227interface StartSendMessageOptions {
217228 /** Awaited before dispatch. Defaults to the hook's in-flight stop, if any. */
@@ -3834,6 +3845,30 @@ export function useChat(
38343845 setError ( 'Previous response is still shutting down; queued message was restored.' )
38353846 return false
38363847 }
3848+ if ( conflictStreamId !== userMessageId ) {
3849+ /* Another turn holds the chat: one started in another tab, or one this
3850+ surface lost track of. This message was not admitted, so it waits in
3851+ the queue behind that turn, which the chat now shows as running. */
3852+ rollbackOptimisticSend ( )
3853+ if ( streamGenRef . current === gen ) {
3854+ streamGenRef . current ++
3855+ abortController . abort ( 'send_conflict:chat_busy' )
3856+ abortControllerRef . current = null
3857+ clearActiveTurn ( )
3858+ setTransportIdle ( )
3859+ }
3860+ if ( requestChatId ) {
3861+ const busyChatId = requestChatId
3862+ upsertChatHistory ( busyChatId , ( current ) => ( {
3863+ ...current ,
3864+ activeStreamId : conflictStreamId ,
3865+ } ) )
3866+ void queryClient . invalidateQueries ( {
3867+ queryKey : mothershipChatKeys . detail ( busyChatId ) ,
3868+ } )
3869+ }
3870+ return { userMessageId }
3871+ }
38373872 /* A send deduplicated against an earlier attempt comes back naming
38383873 the chat that attempt opened. Adopting it here spares a chatless
38393874 surface the stream-to-chat lookup and puts the user in the right
@@ -3953,6 +3988,27 @@ export function useChat(
39533988 return consumedByTranscript
39543989 }
39553990
3991+ if ( ! sendReachedServer ) {
3992+ /* The POST got no answer, so nothing shows the server admitted it, and a
3993+ stream that was never opened reads as finished. Hand the message back
3994+ under the same id: if the server did admit it, the retry deduplicates
3995+ and the chat's own recovery shows the running turn. */
3996+ rollbackOptimisticSend ( )
3997+ if ( gen !== undefined && streamGenRef . current === gen ) {
3998+ streamGenRef . current ++
3999+ admission ?. controller . abort ( 'send_unreachable:no_response' )
4000+ abortControllerRef . current = null
4001+ clearActiveTurn ( )
4002+ setTransportIdle ( )
4003+ }
4004+ setError (
4005+ err instanceof TypeError
4006+ ? 'Message not sent: Sim could not be reached. It will send when you are back online.'
4007+ : getErrorMessage ( err , 'Failed to send message' )
4008+ )
4009+ return { userMessageId, unreachable : true }
4010+ }
4011+
39564012 const activeStreamId = streamIdRef . current
39574013 if ( activeStreamId && gen !== undefined && streamGenRef . current === gen ) {
39584014 const succeeded = await retryReconnect ( {
@@ -4110,11 +4166,12 @@ export function useChat(
41104166 const result = await startSendMessage ( message , fileAttachments , contexts , options )
41114167 if ( typeof result !== 'object' ) return
41124168
4113- /* An unmount cleanup withdrew the send. A chat-bound key is the stable
4114- chat id, so re-queueing under the key this was sent to is the durable
4115- retry — and keeps the message in that chat rather than following the
4116- user into whichever one they opened next. Only a chatless surface,
4117- whose key dies with the mount, goes to the cross-surface lanes. */
4169+ /* The send was withdrawn. A chat-bound key is the stable chat id, so
4170+ re-queueing under the key this was sent to is the durable retry, and
4171+ keeps the message in that chat rather than following the user into
4172+ whichever one they opened next. Only a send an unmount withdrew from a
4173+ chatless surface, whose key dies with the mount, goes to the
4174+ cross-surface lanes. */
41184175 const withdrawn = {
41194176 content : message ,
41204177 fileAttachments,
@@ -4126,24 +4183,22 @@ export function useChat(
41264183 ? { assistantSearchLevel : options ?. assistantSearchLevel }
41274184 : { } ) ,
41284185 }
4129- if ( activeChatKey . startsWith ( PENDING_CHAT_KEY_PREFIX ) ) {
4186+ if ( ! result . unreachable && activeChatKey . startsWith ( PENDING_CHAT_KEY_PREFIX ) ) {
41304187 handOffWithdrawnSend ( withdrawn )
41314188 return
41324189 }
4133- useMothershipQueueStore
4134- . getState ( )
4135- . enqueue (
4136- activeChatKey ,
4137- createQueuedMessage (
4138- message ,
4139- fileAttachments ,
4140- contexts ,
4141- result . userMessageId ,
4142- options ?. requestMode ,
4143- options ?. assistantSearch ,
4144- options ?. assistantSearchLevel
4145- )
4146- )
4190+ useMothershipQueueStore . getState ( ) . enqueue ( activeChatKey , {
4191+ ...createQueuedMessage (
4192+ message ,
4193+ fileAttachments ,
4194+ contexts ,
4195+ result . userMessageId ,
4196+ options ?. requestMode ,
4197+ options ?. assistantSearch ,
4198+ options ?. assistantSearchLevel
4199+ ) ,
4200+ ...( result . unreachable ? { retryRequired : true , heldUntilOnline : true } : { } ) ,
4201+ } )
41474202 } ,
41484203 [
41494204 workspaceId ,
@@ -4696,9 +4751,10 @@ export function useChat(
46964751 let dispatched = msg
46974752 const restoreQueuedMessage = (
46984753 handoff ?: QueuedSendHandoffSeed ,
4699- withdrawnUserMessageId ?: string
4754+ withdrawn ?: WithdrawnSendResult
47004755 ) => {
4701- const withdrawnByCleanup = withdrawnUserMessageId !== undefined
4756+ const withdrawnUserMessageId = withdrawn ?. userMessageId
4757+ const retriesOnItsOwn = withdrawn !== undefined && ! withdrawn . unreachable
47024758 const savedHandoff = readQueuedSendHandoffState ( )
47034759 const retainedHandoff =
47044760 savedHandoff ?. id === msg . id
@@ -4714,7 +4770,7 @@ export function useChat(
47144770 if ( ! removedFromQueue ) {
47154771 return
47164772 }
4717- if ( options . epoch !== queueDispatchEpochRef . current && ! withdrawnByCleanup ) {
4773+ if ( options . epoch !== queueDispatchEpochRef . current && ! retriesOnItsOwn ) {
47184774 return
47194775 }
47204776 // If the user explicitly removed this message during dispatch, honor
@@ -4727,7 +4783,7 @@ export function useChat(
47274783 restore would strand this under the dead instance's key — hand it to
47284784 the next surface instead. A chat-bound key is the stable chat id, so
47294785 the queue itself is the durable retry. */
4730- if ( withdrawnByCleanup && dispatchChatKey . startsWith ( PENDING_CHAT_KEY_PREFIX ) ) {
4786+ if ( withdrawn && retriesOnItsOwn && dispatchChatKey . startsWith ( PENDING_CHAT_KEY_PREFIX ) ) {
47314787 clearQueuedSendHandoffState ( msg . id )
47324788 handOffWithdrawnSend ( {
47334789 content : dispatched . content ,
@@ -4738,7 +4794,7 @@ export function useChat(
47384794 ...( dispatched . assistantSearchLevel !== undefined
47394795 ? { assistantSearchLevel : dispatched . assistantSearchLevel }
47404796 : { } ) ,
4741- userMessageId : withdrawnUserMessageId ,
4797+ userMessageId : withdrawn . userMessageId ,
47424798 } )
47434799 return
47444800 }
@@ -4747,7 +4803,8 @@ export function useChat(
47474803 useMothershipQueueStore . getState ( ) . insertAt ( dispatchChatKey , originalIndex , {
47484804 ...dispatched ,
47494805 ...( retainedHandoff ? { queuedSendHandoff : retainedHandoff } : { } ) ,
4750- retryRequired : ! withdrawnByCleanup ,
4806+ retryRequired : ! retriesOnItsOwn ,
4807+ ...( withdrawn ?. unreachable ? { heldUntilOnline : true } : { } ) ,
47514808 ...( withdrawnUserMessageId ? { resumeUserMessageId : withdrawnUserMessageId } : { } ) ,
47524809 } )
47534810 }
@@ -4791,7 +4848,7 @@ export function useChat(
47914848 if ( sendResult !== true ) {
47924849 restoreQueuedMessage (
47934850 activeQueuedSendHandoff ,
4794- typeof sendResult === 'object' ? sendResult . userMessageId : undefined
4851+ typeof sendResult === 'object' ? sendResult : undefined
47954852 )
47964853 }
47974854 } catch {
@@ -4956,6 +5013,22 @@ export function useChat(
49565013 }
49575014 } , [ ] )
49585015
5016+ /** A send held because the server could not be reached goes out once the browser is back online. */
5017+ useEffect ( ( ) => {
5018+ if ( typeof window === 'undefined' ) return
5019+ const releaseHeldSends = ( ) => {
5020+ const chatKey = chatKeyRef . current
5021+ const queue = useMothershipQueueStore . getState ( ) . queues [ chatKey ]
5022+ if ( ! queue ?. some ( ( message ) => message . heldUntilOnline ) ) return
5023+ useMothershipQueueStore . getState ( ) . releaseHeldUntilOnline ( chatKey )
5024+ if ( ! sendingRef . current && ! pendingStopPromiseRef . current ) {
5025+ void enqueueQueueDispatchRef . current ( { type : 'send_head' } )
5026+ }
5027+ }
5028+ window . addEventListener ( 'online' , releaseHeldSends )
5029+ return ( ) => window . removeEventListener ( 'online' , releaseHeldSends )
5030+ } , [ ] )
5031+
49595032 /** A recovered send already in history belongs to its accepted turn, even after Stop. */
49605033 useEffect ( ( ) => {
49615034 if ( ! chatHistory || chatHistory . id !== chatKeyRef . current ) return
0 commit comments