diff --git a/client/src/components/Chat/ask/state.tsx b/client/src/components/Chat/ask/state.tsx index 41c41303f6..65a69eaa98 100644 --- a/client/src/components/Chat/ask/state.tsx +++ b/client/src/components/Chat/ask/state.tsx @@ -17,8 +17,15 @@ export const askAnswerCheckedAtom = atom([]); /** Free-form answers keyed by pending action id. */ export const askAnswerTextAtom = atom>({}); -/** Ordinary composer text keyed by pending action id while the card owns it. */ -export const releasedComposerTextAtom = atom>({}); +/** + * Ordinary composer text stashed while the card owns the composer, keyed by + * pending action id. The conversation rides along because every hand-back is + * scoped to it: a resume that settles after the user navigated away leaves no + * one watching the exit, so returning to that conversation is the only + * remaining chance to give the message back. + */ +export type ReleasedComposerStash = { conversationId: string | null; text: string }; +export const releasedComposerTextAtom = atom>({}); /** Submission lifecycle shared by composer and message-content surfaces. */ export type AskAnswerStatus = 'idle' | 'submitting' | 'submitted' | 'expired' | 'error'; diff --git a/client/src/hooks/Input/useAskAnswerMode.spec.ts b/client/src/hooks/Input/useAskAnswerMode.spec.ts index e4e2029165..12e5cf0745 100644 --- a/client/src/hooks/Input/useAskAnswerMode.spec.ts +++ b/client/src/hooks/Input/useAskAnswerMode.spec.ts @@ -268,7 +268,9 @@ describe('useAskAnswerMode', () => { ); it('preserves newer composer edits when an in-flight question expires', () => { - jotaiStore.set(releasedComposerTextAtom, { a1: 'an older message' }); + jotaiStore.set(releasedComposerTextAtom, { + a1: { conversationId: 'conversation-1', text: 'an older message' }, + }); mockUseGetMessages.mockReturnValue({ data: liveAsk }); const { rerender } = renderHook(() => useAskAnswerMode('conversation-1'), { wrapper: JotaiWrapper, @@ -284,7 +286,9 @@ describe('useAskAnswerMode', () => { }); it('does not restore a departed conversation stash into a different composer', () => { - jotaiStore.set(releasedComposerTextAtom, { a1: 'message for conversation A' }); + jotaiStore.set(releasedComposerTextAtom, { + a1: { conversationId: 'conversation-1', text: 'message for conversation A' }, + }); mockUseGetMessages.mockReturnValue({ data: liveAsk }); const { rerender } = renderHook(({ id }) => useAskAnswerMode(id), { initialProps: { id: 'conversation-1' }, @@ -295,16 +299,46 @@ describe('useAskAnswerMode', () => { expect(mockSetComposerText).not.toHaveBeenCalled(); expect(jotaiStore.get(releasedComposerTextAtom)).toEqual({ - a1: 'message for conversation A', + a1: { conversationId: 'conversation-1', text: 'message for conversation A' }, }); }); + it('hands a stash back when its conversation is revisited after the answer settled', () => { + /** Navigating away mid-resume leaves nobody watching the exit, so the + * settle drops the question without restoring anything. The stash has to + * survive until its own conversation comes back, or the ordinary unsent + * message is unreachable for the rest of the session. */ + jotaiStore.set(releasedComposerTextAtom, { + a1: { conversationId: 'conversation-1', text: 'message for conversation A' }, + }); + mockUseGetMessages.mockReturnValue({ data: liveAsk }); + mockAskStatus = 'submitting'; + const { rerender } = renderHook(({ id }) => useAskAnswerMode(id), { + initialProps: { id: 'conversation-1' }, + wrapper: JotaiWrapper, + }); + mockUseGetMessages.mockReturnValue({ data: null }); + rerender({ id: 'conversation-2' }); + expect(mockSetComposerText).not.toHaveBeenCalled(); + + /** Drafts are off, so the revisited composer opens empty — the only state + * in which handing the stash back cannot overwrite something newer. */ + mockGetComposerText.mockReturnValue(''); + mockAskStatus = 'submitted'; + rerender({ id: 'conversation-1' }); + + expect(mockSetComposerText).toHaveBeenLastCalledWith('text', 'message for conversation A'); + expect(jotaiStore.get(releasedComposerTextAtom)).toEqual({}); + }); + 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. */ - jotaiStore.set(releasedComposerTextAtom, { a1: 'an ordinary unsent message' }); + jotaiStore.set(releasedComposerTextAtom, { + a1: { conversationId: 'conversation-1', text: 'an ordinary unsent message' }, + }); mockUseGetMessages.mockReturnValue({ data: liveAsk }); mockSubmitAskAnswer.mockImplementation( (_actionId: string, _answer: string, options?: { onSuccess?: () => void }) => { @@ -328,7 +362,9 @@ describe('useAskAnswerMode', () => { /** Only the free-text path sets `consumedComposerText`, so gating the * restore on it discarded the stash whenever the user answered by * clicking an option (or skipping) instead of typing. */ - jotaiStore.set(releasedComposerTextAtom, { a1: 'an ordinary unsent message' }); + jotaiStore.set(releasedComposerTextAtom, { + a1: { conversationId: 'conversation-1', text: 'an ordinary unsent message' }, + }); const askWithOptions = { actionId: 'a1', question: { diff --git a/client/src/hooks/Input/useAskAnswerMode.ts b/client/src/hooks/Input/useAskAnswerMode.ts index c9fc612827..561830d229 100644 --- a/client/src/hooks/Input/useAskAnswerMode.ts +++ b/client/src/hooks/Input/useAskAnswerMode.ts @@ -162,22 +162,37 @@ export default function useAskAnswerMode(conversationId?: string | null) { return; } lastAnswerableRef.current = null; - if (!previous || previous.conversationId !== conversationId) { + /** Recovery is keyed by the stash's own conversation, not by what this + * hook happened to observe. Navigating away mid-resume clears the ref + * while the stash rightly stays put, and the settle then lands with + * nobody watching that exit — so coming back is the last chance to hand + * the message over, and gating on the ref stranded it in the atom. */ + const stashed = Object.entries(releasedComposerText).find( + ([, stash]) => stash.conversationId === conversationId, + ); + if (!stashed) { return; } - const released = releasedComposerText[previous.actionId]; - if (released == null) { - return; - } - if (!previous.submitting || formContext?.getValues('text') === previous.composerText) { - formContext?.setValue('text', released); + const [actionId, stash] = stashed; + const composerText = formContext?.getValues('text') ?? ''; + /** Only the exit this hook armed knows what the composer held when the + * resume started. On a revisit the composer is whatever the route + * restored, so an empty one is the only target that cannot clobber + * something newer. Either way the entry goes: anything the user typed + * since is newer than the stash. */ + const restorable = + previous != null && previous.actionId === actionId + ? !previous.submitting || composerText === previous.composerText + : composerText === ''; + if (restorable) { + formContext?.setValue('text', stash.text); } setReleasedComposerText((current) => { - if (current[previous.actionId] == null) { + if (current[actionId] == null) { return current; } const next = { ...current }; - delete next[previous.actionId]; + delete next[actionId]; return next; }); }, [ @@ -246,7 +261,7 @@ export default function useAskAnswerMode(conversationId?: string | null) { * saving between expanding and collapsing. */ const released = releasedComposerText[liveAsk.actionId]; if (released) { - formContext?.setValue('text', released); + formContext?.setValue('text', released.text); setReleasedComposerText((current) => { if (current[liveAsk.actionId] == null) { return current; @@ -290,7 +305,7 @@ export default function useAskAnswerMode(conversationId?: string | null) { if (released) { setReleasedComposerText((current) => ({ ...current, - [liveAsk.actionId]: released, + [liveAsk.actionId]: { conversationId: conversationId ?? null, text: released }, })); } formContext?.setValue('text', answerText); @@ -308,6 +323,7 @@ export default function useAskAnswerMode(conversationId?: string | null) { } }, [ liveAsk, + conversationId, batchMode, saveDrafts, formContext, @@ -394,7 +410,7 @@ export default function useAskAnswerMode(conversationId?: string | null) { const released = releasedComposerTextRef.current[submittedActionId]; if (currentScope.formContext?.getValues('text') === submittedComposerText) { if (released) { - currentScope.formContext.setValue('text', released); + currentScope.formContext.setValue('text', released.text); } else if (consumedComposerText || (wasActive && saveDrafts)) { currentScope.formContext.reset(); }