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,