diff --git a/src/pages/AskPage.tsx b/src/pages/AskPage.tsx index 52c2d31a9..39c233e79 100644 --- a/src/pages/AskPage.tsx +++ b/src/pages/AskPage.tsx @@ -84,9 +84,38 @@ export default function AskPage() { setAskLoading(false); }; + // Leaving the page aborts the request. The in-flight handler then sees a new + // request id and will not clear the flags, so do it here or Ask comes back + // stuck on Typing with the composer disabled. A partial answer is kept. + const leaveCleanupRef = useRef({ + abortActiveRequest, + addAskMessage, + setAskLoading, + setAskStreamingAssistant, + stoppedNote: t('ask.stopped', 'You stopped this answer before it finished.'), + }); + leaveCleanupRef.current = { + abortActiveRequest, + addAskMessage, + setAskLoading, + setAskStreamingAssistant, + stoppedNote: t('ask.stopped', 'You stopped this answer before it finished.'), + }; useEffect(() => () => { - abortActiveRequest(); - }, [abortActiveRequest]); + const cleanup = leaveCleanupRef.current; + const partial = useSessionStore.getState().askStreamingAssistant; + cleanup.abortActiveRequest(); + cleanup.setAskLoading(false); + cleanup.setAskStreamingAssistant(null); + const kept = partial?.content.trim(); + if (!kept) return; + cleanup.addAskMessage({ + id: partial?.id, + role: 'assistant', + content: kept, + output: { response: kept, followUpQuestions: [], uncertainties: [cleanup.stoppedNote] }, + }); + }, []); // A reply still in flight belongs to the scope it was asked in; a new debate // or one-off mode starts a fresh conversation, so drop the late answer. diff --git a/src/test/pages/AskAccountSwitch.test.tsx b/src/test/pages/AskAccountSwitch.test.tsx index 92035fd5a..0656610e6 100644 --- a/src/test/pages/AskAccountSwitch.test.tsx +++ b/src/test/pages/AskAccountSwitch.test.tsx @@ -61,4 +61,22 @@ describe('Ask across an account change', () => { expect(screen.queryByText(/Account A private answer/)).not.toBeInTheDocument(); expect(useSessionStore.getState().askStreamingAssistant).toBeNull(); }); + + it('does not stay on Typing after you leave mid-answer', async () => { + mocks.userId = 'user-a'; + enableOneOffMode(); + useSessionStore.getState().clearAskMessages(); + const view = render(); + fireEvent.change(screen.getByPlaceholderText('Ask your debate coach...'), { target: { value: 'How do I weigh cost?' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send message' })); + await waitFor(() => expect(mocks.live).toBeDefined()); + act(() => mocks.live!.onDraft({ response: 'A partial answer' })); + expect(useSessionStore.getState().askLoading).toBe(true); + + view.unmount(); + + expect(useSessionStore.getState().askLoading).toBe(false); + expect(useSessionStore.getState().askStreamingAssistant).toBeNull(); + expect(useSessionStore.getState().askMessages.map((message) => message.content)).toContain('A partial answer'); + }); });