From 188ad89ddf9393a09dc8c659fbdeeb8dbcf8a68c Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:09:45 -0700 Subject: [PATCH] fix(chat): a refused send puts its text back in the composer (TASK-503 follow-up) Lark's two non-blocking notes on #2989. ChatRoom now restores the refused text to the composer unless the person has started typing something else, so a retry is one keystroke. A backend test pins ChatRoom's on/off listener literal to the server's MESSAGE_SEND_FAILED_EVENT constant, so a rename on either side fails CI instead of dropping refusals silently. Each new assertion fails against its mutant. Co-Authored-By: Claude Opus 5.5 Co-authored-by: Ember Agent-Model: claude/claude-opus-5-5/high --- .../unit/services/socketMessageWrite.test.js | 19 ++++++++++++++++++- frontend/src/components/ChatRoom.test.tsx | 7 ++++++- frontend/src/components/ChatRoom.tsx | 5 +++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/backend/__tests__/unit/services/socketMessageWrite.test.js b/backend/__tests__/unit/services/socketMessageWrite.test.js index b813f875e7..f7a94b17bc 100644 --- a/backend/__tests__/unit/services/socketMessageWrite.test.js +++ b/backend/__tests__/unit/services/socketMessageWrite.test.js @@ -9,11 +9,15 @@ jest.mock('../../../models/pg/Pod', () => ({ findById: jest.fn() })); jest.mock('../../../services/pgPodSyncService', () => ({ syncPodFromMongo: jest.fn() })); jest.mock('../../../models/Message', () => jest.fn()); +const fs = require('fs'); +const path = require('path'); const PGMessage = require('../../../models/pg/Message'); const PGPod = require('../../../models/pg/Pod'); const { syncPodFromMongo } = require('../../../services/pgPodSyncService'); const MongoMessage = require('../../../models/Message'); -const { writeSocketMessage, MESSAGE_STORE_UNAVAILABLE } = require('../../../services/socketMessageWrite'); +const { + writeSocketMessage, MESSAGE_STORE_UNAVAILABLE, MESSAGE_SEND_FAILED_EVENT, +} = require('../../../services/socketMessageWrite'); // The refusal names the send so the client can drop its optimistic copy. const REFUSED = { @@ -85,3 +89,16 @@ describe('writeSocketMessage', () => { .toBeLessThan(PGMessage.create.mock.invocationCallOrder[0]); }); }); + +describe('the refused-send event name', () => { + // The server names the event in one constant; the legacy ChatRoom listens + // for it by literal. A rename on either side would drop every refusal + // silently, so pin the two together. + it('is the event ChatRoom subscribes to and unsubscribes from', () => { + const chatRoomPath = path.resolve(__dirname, '../../../../frontend/src/components/ChatRoom.tsx'); + const chatRoom = fs.readFileSync(chatRoomPath, 'utf8'); + + expect(chatRoom).toContain(`socket.on('${MESSAGE_SEND_FAILED_EVENT}', handleSendFailed)`); + expect(chatRoom).toContain(`socket.off('${MESSAGE_SEND_FAILED_EVENT}', handleSendFailed)`); + }); +}); diff --git a/frontend/src/components/ChatRoom.test.tsx b/frontend/src/components/ChatRoom.test.tsx index 9c44b8e2f8..0319863302 100644 --- a/frontend/src/components/ChatRoom.test.tsx +++ b/frontend/src/components/ChatRoom.test.tsx @@ -315,7 +315,12 @@ test('a send the server refuses is removed and shown in the composer (TASK-503)' }); }); - expect(container.textContent).not.toContain('store is down'); + // The optimistic bubble is gone (the composer field is not part of the list). + const listOnly = container.cloneNode(true); + listOnly.querySelectorAll('textarea, input').forEach((field) => field.remove()); + expect(listOnly.textContent).not.toContain('store is down'); + // The refused text is back in the composer, ready to resend. + expect(container.querySelector('[placeholder="Message #Room"]').value).toBe('store is down'); expect(container.querySelector('[role="alert"]').textContent) .toContain('Messages are temporarily unavailable. Please try again shortly.'); // The room stays mounted and listening: the refusal is not a page error. diff --git a/frontend/src/components/ChatRoom.tsx b/frontend/src/components/ChatRoom.tsx index 3e6e9749db..2c3fe9e140 100644 --- a/frontend/src/components/ChatRoom.tsx +++ b/frontend/src/components/ChatRoom.tsx @@ -1932,6 +1932,11 @@ const ChatRoom = () => { if (i < 0) return prevMessages; return [...prevMessages.slice(0, i), ...prevMessages.slice(i + 1)]; }); + // Give the refused text back so a retry is one keystroke, unless + // the person has already started typing something else. + if (failure.messageType === 'text' && failure.content) { + setMessage(current => current || failure.content); + } setSendError(failure.message || 'Failed to send message. Please try again.'); }; socket.on('messageSendFailed', handleSendFailed);