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.
This commit is contained in:
Marco Beretta 2026-08-28 23:17:35 +02:00
parent 05d53c5535
commit f84750ef22
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
2 changed files with 60 additions and 1 deletions

View file

@ -14,6 +14,15 @@ const mockSetAnswerDrafts = jest.fn((update: unknown) => {
? (update as (current: Record<string, string>) => Record<string, string>)(mockAnswerDrafts)
: (update as Record<string, string>);
});
let mockReleasedComposerText: Record<string, string> = {};
const mockSetReleasedComposerText = jest.fn((update: unknown) => {
mockReleasedComposerText =
typeof update === 'function'
? (update as (current: Record<string, string>) => Record<string, string>)(
mockReleasedComposerText,
)
: (update as Record<string, string>);
});
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 });

View file

@ -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,