@@ -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. */
@@ -3840,6 +3851,30 @@ export function useChat(
38403851 setError ( 'Previous response is still shutting down; queued message was restored.' )
38413852 return false
38423853 }
3854+ if ( conflictStreamId !== userMessageId ) {
3855+ /* Another turn holds the chat: one started in another tab, or one this
3856+ surface lost track of. This message was not admitted, so it waits in
3857+ the queue behind that turn, which the chat now shows as running. */
3858+ rollbackOptimisticSend ( )
3859+ if ( streamGenRef . current === gen ) {
3860+ streamGenRef . current ++
3861+ abortController . abort ( 'send_conflict:chat_busy' )
3862+ abortControllerRef . current = null
3863+ clearActiveTurn ( )
3864+ setTransportIdle ( )
3865+ }
3866+ if ( requestChatId ) {
3867+ const busyChatId = requestChatId
3868+ upsertChatHistory ( busyChatId , ( current ) => ( {
3869+ ...current ,
3870+ activeStreamId : conflictStreamId ,
3871+ } ) )
3872+ void queryClient . invalidateQueries ( {
3873+ queryKey : mothershipChatKeys . detail ( busyChatId ) ,
3874+ } )
3875+ }
3876+ return { userMessageId }
3877+ }
38433878 /* A send deduplicated against an earlier attempt comes back naming
38443879 the chat that attempt opened. Adopting it here spares a chatless
38453880 surface the stream-to-chat lookup and puts the user in the right
@@ -3959,6 +3994,27 @@ export function useChat(
39593994 return consumedByTranscript
39603995 }
39613996
3997+ if ( ! sendReachedServer ) {
3998+ /* The POST got no answer, so nothing shows the server admitted it, and a
3999+ stream that was never opened reads as finished. Hand the message back
4000+ under the same id: if the server did admit it, the retry deduplicates
4001+ and the chat's own recovery shows the running turn. */
4002+ rollbackOptimisticSend ( )
4003+ if ( gen !== undefined && streamGenRef . current === gen ) {
4004+ streamGenRef . current ++
4005+ admission ?. controller . abort ( 'send_unreachable:no_response' )
4006+ abortControllerRef . current = null
4007+ clearActiveTurn ( )
4008+ setTransportIdle ( )
4009+ }
4010+ setError (
4011+ err instanceof TypeError
4012+ ? 'Message not sent: Sim could not be reached. It will send when you are back online.'
4013+ : getErrorMessage ( err , 'Failed to send message' )
4014+ )
4015+ return { userMessageId, unreachable : true }
4016+ }
4017+
39624018 const activeStreamId = streamIdRef . current
39634019 if ( activeStreamId && gen !== undefined && streamGenRef . current === gen ) {
39644020 const succeeded = await retryReconnect ( {
@@ -4116,11 +4172,12 @@ export function useChat(
41164172 const result = await startSendMessage ( message , fileAttachments , contexts , options )
41174173 if ( typeof result !== 'object' ) return
41184174
4119- /* An unmount cleanup withdrew the send. A chat-bound key is the stable
4120- chat id, so re-queueing under the key this was sent to is the durable
4121- retry — and keeps the message in that chat rather than following the
4122- user into whichever one they opened next. Only a chatless surface,
4123- whose key dies with the mount, goes to the cross-surface lanes. */
4175+ /* The send was withdrawn. A chat-bound key is the stable chat id, so
4176+ re-queueing under the key this was sent to is the durable retry, and
4177+ keeps the message in that chat rather than following the user into
4178+ whichever one they opened next. Only a send an unmount withdrew from a
4179+ chatless surface, whose key dies with the mount, goes to the
4180+ cross-surface lanes. */
41244181 const withdrawn = {
41254182 content : message ,
41264183 fileAttachments,
@@ -4132,24 +4189,22 @@ export function useChat(
41324189 ? { assistantSearchLevel : options ?. assistantSearchLevel }
41334190 : { } ) ,
41344191 }
4135- if ( activeChatKey . startsWith ( PENDING_CHAT_KEY_PREFIX ) ) {
4192+ if ( ! result . unreachable && activeChatKey . startsWith ( PENDING_CHAT_KEY_PREFIX ) ) {
41364193 handOffWithdrawnSend ( withdrawn )
41374194 return
41384195 }
4139- useMothershipQueueStore
4140- . getState ( )
4141- . enqueue (
4142- activeChatKey ,
4143- createQueuedMessage (
4144- message ,
4145- fileAttachments ,
4146- contexts ,
4147- result . userMessageId ,
4148- options ?. requestMode ,
4149- options ?. assistantSearch ,
4150- options ?. assistantSearchLevel
4151- )
4152- )
4196+ useMothershipQueueStore . getState ( ) . enqueue ( activeChatKey , {
4197+ ...createQueuedMessage (
4198+ message ,
4199+ fileAttachments ,
4200+ contexts ,
4201+ result . userMessageId ,
4202+ options ?. requestMode ,
4203+ options ?. assistantSearch ,
4204+ options ?. assistantSearchLevel
4205+ ) ,
4206+ ...( result . unreachable ? { retryRequired : true , heldUntilOnline : true } : { } ) ,
4207+ } )
41534208 } ,
41544209 [
41554210 workspaceId ,
@@ -4702,9 +4757,10 @@ export function useChat(
47024757 let dispatched = msg
47034758 const restoreQueuedMessage = (
47044759 handoff ?: QueuedSendHandoffSeed ,
4705- withdrawnUserMessageId ?: string
4760+ withdrawn ?: WithdrawnSendResult
47064761 ) => {
4707- const withdrawnByCleanup = withdrawnUserMessageId !== undefined
4762+ const withdrawnUserMessageId = withdrawn ?. userMessageId
4763+ const retriesOnItsOwn = withdrawn !== undefined && ! withdrawn . unreachable
47084764 const savedHandoff = readQueuedSendHandoffState ( )
47094765 const retainedHandoff =
47104766 savedHandoff ?. id === msg . id
@@ -4720,7 +4776,7 @@ export function useChat(
47204776 if ( ! removedFromQueue ) {
47214777 return
47224778 }
4723- if ( options . epoch !== queueDispatchEpochRef . current && ! withdrawnByCleanup ) {
4779+ if ( options . epoch !== queueDispatchEpochRef . current && ! retriesOnItsOwn ) {
47244780 return
47254781 }
47264782 // If the user explicitly removed this message during dispatch, honor
@@ -4733,7 +4789,7 @@ export function useChat(
47334789 restore would strand this under the dead instance's key — hand it to
47344790 the next surface instead. A chat-bound key is the stable chat id, so
47354791 the queue itself is the durable retry. */
4736- if ( withdrawnByCleanup && dispatchChatKey . startsWith ( PENDING_CHAT_KEY_PREFIX ) ) {
4792+ if ( withdrawn && retriesOnItsOwn && dispatchChatKey . startsWith ( PENDING_CHAT_KEY_PREFIX ) ) {
47374793 clearQueuedSendHandoffState ( msg . id )
47384794 handOffWithdrawnSend ( {
47394795 content : dispatched . content ,
@@ -4744,7 +4800,7 @@ export function useChat(
47444800 ...( dispatched . assistantSearchLevel !== undefined
47454801 ? { assistantSearchLevel : dispatched . assistantSearchLevel }
47464802 : { } ) ,
4747- userMessageId : withdrawnUserMessageId ,
4803+ userMessageId : withdrawn . userMessageId ,
47484804 } )
47494805 return
47504806 }
@@ -4753,7 +4809,8 @@ export function useChat(
47534809 useMothershipQueueStore . getState ( ) . insertAt ( dispatchChatKey , originalIndex , {
47544810 ...dispatched ,
47554811 ...( retainedHandoff ? { queuedSendHandoff : retainedHandoff } : { } ) ,
4756- retryRequired : ! withdrawnByCleanup ,
4812+ retryRequired : ! retriesOnItsOwn ,
4813+ ...( withdrawn ?. unreachable ? { heldUntilOnline : true } : { } ) ,
47574814 ...( withdrawnUserMessageId ? { resumeUserMessageId : withdrawnUserMessageId } : { } ) ,
47584815 } )
47594816 }
@@ -4797,7 +4854,7 @@ export function useChat(
47974854 if ( sendResult !== true ) {
47984855 restoreQueuedMessage (
47994856 activeQueuedSendHandoff ,
4800- typeof sendResult === 'object' ? sendResult . userMessageId : undefined
4857+ typeof sendResult === 'object' ? sendResult : undefined
48014858 )
48024859 }
48034860 } catch {
@@ -4962,6 +5019,22 @@ export function useChat(
49625019 }
49635020 } , [ ] )
49645021
5022+ /** A send held because the server could not be reached goes out once the browser is back online. */
5023+ useEffect ( ( ) => {
5024+ if ( typeof window === 'undefined' ) return
5025+ const releaseHeldSends = ( ) => {
5026+ const chatKey = chatKeyRef . current
5027+ const queue = useMothershipQueueStore . getState ( ) . queues [ chatKey ]
5028+ if ( ! queue ?. some ( ( message ) => message . heldUntilOnline ) ) return
5029+ useMothershipQueueStore . getState ( ) . releaseHeldUntilOnline ( chatKey )
5030+ if ( ! sendingRef . current && ! pendingStopPromiseRef . current ) {
5031+ void enqueueQueueDispatchRef . current ( { type : 'send_head' } )
5032+ }
5033+ }
5034+ window . addEventListener ( 'online' , releaseHeldSends )
5035+ return ( ) => window . removeEventListener ( 'online' , releaseHeldSends )
5036+ } , [ ] )
5037+
49655038 /** A recovered send already in history belongs to its accepted turn, even after Stop. */
49665039 useEffect ( ( ) => {
49675040 if ( ! chatHistory || chatHistory . id !== chatKeyRef . current ) return
0 commit comments