From f84750ef22f41fc1ff684d20856ed7f292b94ad8 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:17:35 +0200 Subject: [PATCH] fix(client): restore the released composer text after answering Submitting an answer from the expanded card never goes through collapse, so the ordinary unsent message stashed when the question was moved back was never handed over and its entry leaked. The resume success path now restores it in place of the reset when one is stashed, and drops the entry either way. Read through a ref because the callback settles long after the render that armed it. --- .../src/hooks/Input/useAskAnswerMode.spec.ts | 36 +++++++++++++++++++ client/src/hooks/Input/useAskAnswerMode.ts | 25 ++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/client/src/hooks/Input/useAskAnswerMode.spec.ts b/client/src/hooks/Input/useAskAnswerMode.spec.ts index 6f59bf0c1e..7122122946 100644 --- a/client/src/hooks/Input/useAskAnswerMode.spec.ts +++ b/client/src/hooks/Input/useAskAnswerMode.spec.ts @@ -14,6 +14,15 @@ const mockSetAnswerDrafts = jest.fn((update: unknown) => { ? (update as (current: Record) => Record)(mockAnswerDrafts) : (update as Record); }); +let mockReleasedComposerText: Record = {}; +const mockSetReleasedComposerText = jest.fn((update: unknown) => { + mockReleasedComposerText = + typeof update === 'function' + ? (update as (current: Record) => Record)( + mockReleasedComposerText, + ) + : (update as Record); +}); const mockSetDraft = jest.fn(); let mockSaveDrafts = false; let mockCollapsedIds: string[] = []; @@ -50,6 +59,9 @@ jest.mock('recoil', () => ({ if (state.key === 'askAnswerModeText') { return [mockAnswerDrafts, mockSetAnswerDrafts]; } + if (state.key === 'askAnswerModeReleasedComposerText') { + return [mockReleasedComposerText, mockSetReleasedComposerText]; + } return [[], jest.fn()]; }, useRecoilValue: () => mockSaveDrafts, @@ -82,6 +94,7 @@ describe('useAskAnswerMode', () => { mockSaveDrafts = false; mockCollapsedIds = []; mockAnswerDrafts = {}; + mockReleasedComposerText = {}; mockGetComposerText.mockReturnValue('answer from A'); }); @@ -230,6 +243,29 @@ describe('useAskAnswerMode', () => { expect(mockAnswerDrafts).toEqual({ a1: 'answer from A', a2: 'answer from B' }); }); + it('hands the released composer text back when the answer is submitted', () => { + /** Stashed by a prior expand: the question was collapsed, an ordinary + * message was typed in the released composer, then the question was moved + * back. Submitting from there never goes through `collapse`, so the + * message has to be restored here or it dies with the question. */ + mockReleasedComposerText = { a1: 'an ordinary unsent message' }; + mockUseGetMessages.mockReturnValue({ data: liveAsk }); + mockSubmitAskAnswer.mockImplementation( + (_actionId: string, _answer: string, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.(); + }, + ); + const { result } = renderHook(() => useAskAnswerMode('conversation-1')); + + act(() => { + result.current.submitText('answer from A'); + }); + + expect(mockSetComposerText).toHaveBeenLastCalledWith('text', 'an ordinary unsent message'); + expect(mockResetComposer).not.toHaveBeenCalled(); + expect(mockReleasedComposerText).toEqual({}); + }); + it('does not let a delayed answer success clear the composer or selection after navigation', () => { let finishAnswer: (() => void) | undefined; mockUseGetMessages.mockReturnValue({ data: liveAsk }); diff --git a/client/src/hooks/Input/useAskAnswerMode.ts b/client/src/hooks/Input/useAskAnswerMode.ts index d632fed146..182012c2b7 100644 --- a/client/src/hooks/Input/useAskAnswerMode.ts +++ b/client/src/hooks/Input/useAskAnswerMode.ts @@ -121,6 +121,10 @@ export default function useAskAnswerMode(conversationId?: string | null) { actionId: liveAsk?.actionId, formContext, }; + /** Read by the resume success callback, which settles long after the render + * that armed it, so it must not close over a stale stash. */ + const releasedComposerTextRef = useRef(releasedComposerText); + releasedComposerTextRef.current = releasedComposerText; const mountedRef = useRef(true); useEffect(() => { // Strict Mode runs setup, cleanup, then setup again in development; each @@ -364,12 +368,30 @@ export default function useAskAnswerMode(conversationId?: string | null) { delete next[submittedActionId]; return next; }); + /** A successful answer never goes through `collapse`, so the released + * text has to be handed back here too or the user's ordinary + * message dies with the question. Restored in place of the reset + * when one is stashed; the entry is dropped either way so the map + * stays bounded. */ + const released = releasedComposerTextRef.current[submittedActionId]; if ( (consumedComposerText || (wasActive && saveDrafts)) && currentScope.formContext?.getValues('text') === submittedComposerText ) { - currentScope.formContext.reset(); + if (released) { + currentScope.formContext.setValue('text', released); + } else { + currentScope.formContext.reset(); + } } + setReleasedComposerText((current) => { + if (current[submittedActionId] == null) { + return current; + } + const next = { ...current }; + delete next[submittedActionId]; + return next; + }); }, }); return true; @@ -380,6 +402,7 @@ export default function useAskAnswerMode(conversationId?: string | null) { active, saveDrafts, conversationId, + setReleasedComposerText, formContext, submitAskAnswer, setSelected,