diff --git a/src/workspaces/hooks/src/useChatWebSocket.ts b/src/workspaces/hooks/src/useChatWebSocket.ts index 776da795..5d539abe 100644 --- a/src/workspaces/hooks/src/useChatWebSocket.ts +++ b/src/workspaces/hooks/src/useChatWebSocket.ts @@ -20,6 +20,20 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar const onOpenRef = useRef(onOpen); onOpenRef.current = onOpen; + /** + * Messages typed before the socket was ready. + * + * `send` used to drop them: `readyState !== OPEN` returned, silently, with no error and no retry — so + * pressing enter did nothing and the turn never happened. That window is not rare. React's dev + * StrictMode double-invokes effects, so every socket is created, closed and recreated on mount, and a + * reconnect after a drop reopens it again; with several chat panes on screen there are several sockets + * doing this at once. One of them is always briefly not OPEN. + * + * Queued and flushed on open, in order. The mobile chat app does exactly this and for exactly this + * reason — the composer is allowed to fire before the transport is ready. + */ + const pendingRef = useRef([]); + const connect = () => { if (isCleaningUpRef.current) return; if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) return; @@ -31,7 +45,12 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar if (socketRef.current !== socket) return; setIsConnected(true); retryRef.current = 0; + // BEFORE onOpen, deliberately: onOpen sends the resume/attach handshake, and anything the user + // typed while connecting belongs after that, not in front of it. + const queued = pendingRef.current; + pendingRef.current = []; onOpenRef.current?.(); + for (const message of queued) socket.send(message); }); socket.addEventListener('message', (ev) => { @@ -76,8 +95,16 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar const send = (data: Record) => { const socket = socketRef.current; - if (!socket || socket.readyState !== WebSocket.OPEN) return; - socket.send(JSON.stringify(data)); + const message = JSON.stringify(data); + if (socket && socket.readyState === WebSocket.OPEN) { + socket.send(message); + return; + } + // Not open yet, or reconnecting. Hold it rather than dropping it — see `pendingRef`. Bounded so a + // socket that never comes back cannot grow this without limit; the oldest go first, because the + // newest message is the one the user is still waiting on. + pendingRef.current.push(message); + if (pendingRef.current.length > 50) pendingRef.current.shift(); }; return { isConnected, send };