From a23e8828aec29e27f4fdd06fa94d28aa3c66f5e5 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:37:23 +0200 Subject: [PATCH] Fix terminal answer handoff and encoded edit previews --- .../Chat/Messages/Content/ContentParts.tsx | 4 + .../Content/Parts/FileAuthoringCall.tsx | 21 ++++- .../__tests__/FileAuthoringCall.test.tsx | 84 ++++++++++++++----- .../Chat/Messages/Content/ToolCallGroup.tsx | 5 +- .../ContentParts.integration.test.tsx | 4 + .../Content/__tests__/ContentParts.test.tsx | 1 + .../Content/__tests__/ToolCallGroup.test.tsx | 1 + .../components/Chat/Messages/MessageParts.tsx | 4 + .../Chat/Subagents/SubagentActivity.tsx | 4 + .../Chat/Subagents/SubagentConversation.tsx | 4 + .../src/components/Messages/ContentRender.tsx | 4 + .../src/hooks/Input/useAskAnswerMode.spec.ts | 68 ++++++++++++++- client/src/hooks/Input/useAskAnswerMode.ts | 65 +++++++++++++- 13 files changed, 235 insertions(+), 34 deletions(-) diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index 209de9bf9c..0db9c2b8c5 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -206,6 +206,7 @@ type ContentPartsProps = { attachments?: TAttachment[]; searchResults?: { [key: string]: SearchResultData }; isCreatedByUser: boolean; + showThinking: boolean; isLast: boolean; isSubmitting: boolean; isLatestMessage?: boolean; @@ -269,6 +270,7 @@ const ContentPartsBody = memo(function ContentPartsBody({ authorHeader, conversationId, isCreatedByUser, + showThinking, isLatestMessage, createdAt, nestedActivityPhase = false, @@ -811,6 +813,7 @@ const ContentPartsBody = memo(function ContentPartsBody({ isSubmitting={isSubmitting} isLatestMessage={isLatestMessage} nestedActivityPhase + showThinking={showThinking} withinActivityPhase={withinPhase} cursorOwnedElsewhere={cursorOwnedByCard} hideAttachments={hoisted} @@ -1057,6 +1060,7 @@ const ContentPartsBody = memo(function ContentPartsBody({ lastContentIdx={lastContentIdx} groupAttachments={group.groupAttachments} initialExpansionState={expansionState.get(groupId)} + showThinking={showThinking} onExpansionChange={(state) => handleGroupExpansionChange(groupId, state)} labelPart={group.labelPart} withinActivityPhase={withinActivityPhase} diff --git a/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx b/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx index ca805c0b19..0fb96450b9 100644 --- a/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx @@ -51,18 +51,31 @@ function textValue(value: unknown): string { return typeof value === 'string' ? value : ''; } +function coerceJsonValue(value: unknown): unknown { + if (typeof value !== 'string') { + return value; + } + try { + return JSON.parse(value); + } catch { + return value; + } +} + /** Structured edits for the args preview. Never formatted into diff text: * `buildEditPreviewDiff` renders them directly, so no separator has to be * invented and later told apart from real content. */ function buildEditArgsPreview(args: ToolCallArgs): TextEditPreview[] { const parsed = parseArgsObject(args); - if (Array.isArray(parsed?.edits) && parsed.edits.length > 0) { - return parsed.edits + const edits = coerceJsonValue(parsed?.edits); + if (Array.isArray(edits) && edits.length > 0) { + return edits .map((edit): TextEditPreview | undefined => { - if (typeof edit !== 'object' || edit === null || Array.isArray(edit)) { + const coercedEdit = coerceJsonValue(edit); + if (typeof coercedEdit !== 'object' || coercedEdit === null || Array.isArray(coercedEdit)) { return undefined; } - const entry = edit as Record; + const entry = coercedEdit as Record; const oldText = textValue(entry.old_text); const newText = textValue(entry.new_text); return oldText || newText ? { oldText, newText } : undefined; diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/FileAuthoringCall.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/FileAuthoringCall.test.tsx index d65575ecbc..7fa74e2e96 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/FileAuthoringCall.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/FileAuthoringCall.test.tsx @@ -158,8 +158,8 @@ describe('FileAuthoringCall', () => { />, ); - expect(screen.getByText('old line').closest('div')).toHaveClass('bg-status-error-subtle'); - expect(screen.getByText('new line').closest('div')).toHaveClass('bg-status-success-subtle'); + expect(screen.getByText('old line')).toBeInTheDocument(); + expect(screen.getByText('new line')).toBeInTheDocument(); expect(screen.queryByText(/--- old_text/)).not.toBeInTheDocument(); }); @@ -178,8 +178,8 @@ describe('FileAuthoringCall', () => { />, ); - expect(screen.getByText('missing text').closest('div')).toHaveClass('bg-status-error-subtle'); - expect(screen.getByText('replacement').closest('div')).toHaveClass('bg-status-success-subtle'); + expect(screen.getByText('missing text')).toBeInTheDocument(); + expect(screen.getByText('replacement')).toBeInTheDocument(); expect(screen.getByText(/matched 0 locations/)).toBeInTheDocument(); }); @@ -201,12 +201,8 @@ describe('FileAuthoringCall', () => { expect(screen.getByTestId('progress-text')).toHaveTextContent('Editing SKILL.md'); expect(screen.getByTestId('code-window-header')).toHaveAttribute('data-language', 'SKILL.md'); expect(screen.queryByText(/--- old_text/)).not.toBeInTheDocument(); - expect(screen.getByText('description: Old behavior').closest('div')).toHaveClass( - 'bg-status-error-subtle', - ); - expect(screen.getByText('description: New behavior').closest('div')).toHaveClass( - 'bg-status-success-subtle', - ); + expect(screen.getByText('description: Old behavior')).toBeInTheDocument(); + expect(screen.getByText('description: New behavior')).toBeInTheDocument(); }); it('streams create_file content from partial JSON string args during run_step_delta', () => { @@ -238,12 +234,8 @@ describe('FileAuthoringCall', () => { />, ); - expect(screen.getByText('description: Old behavior').closest('div')).toHaveClass( - 'bg-status-error-subtle', - ); - expect(screen.getByText('description: New beh').closest('div')).toHaveClass( - 'bg-status-success-subtle', - ); + expect(screen.getByText('description: Old behavior')).toBeInTheDocument(); + expect(screen.getByText('description: New beh')).toBeInTheDocument(); }); it('streams batched edit_file previews from a partial edits array', () => { @@ -262,10 +254,10 @@ describe('FileAuthoringCall', () => { ); const diff = screen.getByTestId('diff-view'); - expect(screen.getByText('first old').closest('div')).toHaveClass('bg-status-error-subtle'); - expect(screen.getByText('first new').closest('div')).toHaveClass('bg-status-success-subtle'); - expect(screen.getByText('second old').closest('div')).toHaveClass('bg-status-error-subtle'); - expect(screen.getByText('second n').closest('div')).toHaveClass('bg-status-success-subtle'); + expect(screen.getByText('first old')).toBeInTheDocument(); + expect(screen.getByText('first new')).toBeInTheDocument(); + expect(screen.getByText('second old')).toBeInTheDocument(); + expect(screen.getByText('second n')).toBeInTheDocument(); expect(diff).toBeInTheDocument(); }); @@ -286,8 +278,56 @@ describe('FileAuthoringCall', () => { />, ); - expect(screen.getByText('first old').closest('div')).toHaveClass('bg-status-error-subtle'); - expect(screen.getByText('second new').closest('div')).toHaveClass('bg-status-success-subtle'); + expect(screen.getByText('first old')).toBeInTheDocument(); + expect(screen.getByText('second new')).toBeInTheDocument(); + }); + it('renders replacements from a JSON-stringified edits batch after workspace update', () => { + render( + , + ); + + expect(screen.getByText('description: Old behavior')).toBeInTheDocument(); + expect(screen.getByText('description: New behavior')).toBeInTheDocument(); + expect(screen.getByText('status: draft')).toBeInTheDocument(); + expect(screen.getByText('status: published')).toBeInTheDocument(); + expect( + screen.getByText('Updated workspace/demo/SKILL.md with 2 exact replacements.'), + ).toBeInTheDocument(); + }); + + it('renders replacements from JSON-stringified edits entries after workspace update', () => { + render( + , + ); + + expect(screen.getByText('enabled: false')).toBeInTheDocument(); + expect(screen.getByText('enabled: true')).toBeInTheDocument(); + expect(screen.getByText('tier: free')).toBeInTheDocument(); + expect(screen.getByText('tier: pro')).toBeInTheDocument(); }); }); diff --git a/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx b/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx index 043a20ed70..a72b52d9c8 100644 --- a/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx @@ -1,5 +1,4 @@ import { useState, useRef, useMemo, useEffect, useCallback } from 'react'; -import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import { Button } from '@librechat/client'; import { ChevronDown, MessageCircleQuestion, Users } from 'lucide-react'; @@ -28,7 +27,6 @@ import { resolveToolCallPhase } from '~/utils/toolCallPhase'; import { AttachmentGroup, ReasoningCompact } from './Parts'; import { isMemoryFailureOutput } from './Parts/MemoryCall'; import { isError, StackedToolIcons } from './ToolOutput'; -import { showThinkingAtom } from '~/store/showThinking'; import { isBashProgrammaticToolCall } from './routing'; import { ASK_USER_QUESTION } from '~/utils/approval'; import SearchVerticals from './verticals'; @@ -201,6 +199,7 @@ interface ToolCallGroupProps { parts: PartWithIndex[]; isSubmitting: boolean; isLast: boolean; + showThinking: boolean; renderPart: ( part: TMessageContentParts, idx: number, @@ -233,6 +232,7 @@ export default function ToolCallGroup({ parts, isSubmitting, isLast, + showThinking, renderPart, lastContentIdx, groupAttachments, @@ -244,7 +244,6 @@ export default function ToolCallGroup({ const localize = useLocalize(); const mcpIconMap = useMCPIconMap(); const mcpServerNames = useMCPServerNames(); - const showThinking = useAtomValue(showThinkingAtom); const rootRef = useRef(null); const cancelLayoutReconcileRef = useRef<(() => void) | null>(null); const retainedForPendingApprovalRef = useRef(false); diff --git a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.integration.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.integration.test.tsx index c9bd3f1228..6ac97dad44 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.integration.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.integration.test.tsx @@ -234,6 +234,7 @@ describe('ContentParts integration: MCP image hoist and grouping', () => { isCreatedByUser: false, isLast: true, isSubmitting: false, + showThinking: false, isLatestMessage: true, }; @@ -597,6 +598,7 @@ describe('ContentParts — synthesized activity folds', () => { messageId: 'msg1', isCreatedByUser: false, isLast: true, + showThinking: false, isSubmitting: false, isLatestMessage: true, }; @@ -952,6 +954,7 @@ describe('ContentParts integration: phase media row', () => { isCreatedByUser: false, isLast: true, isSubmitting: false, + showThinking: false, isLatestMessage: true, }; @@ -1030,6 +1033,7 @@ describe('ContentParts integration: lane groups backed by one agent', () => { isCreatedByUser: false, isLast: true, isSubmitting: false, + showThinking: false, isLatestMessage: true, }; diff --git a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx index 7e3e96c855..bd5a2fb7ec 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx @@ -174,6 +174,7 @@ const baseProps = { isSubmitting: false, isLatestMessage: false, isCreatedByUser: false, + showThinking: false, content: [], }; diff --git a/client/src/components/Chat/Messages/Content/__tests__/ToolCallGroup.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ToolCallGroup.test.tsx index 405ea28194..4373ca252d 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ToolCallGroup.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/ToolCallGroup.test.tsx @@ -259,6 +259,7 @@ describe('ToolCallGroup image hoisting', () => { parts, isSubmitting: false, isLast: false, + showThinking: false, lastContentIdx: 1, renderPart: (_p: TMessageContentParts, idx: number) => (
diff --git a/client/src/components/Chat/Messages/MessageParts.tsx b/client/src/components/Chat/Messages/MessageParts.tsx index 60a4754e33..19aee8b90c 100644 --- a/client/src/components/Chat/Messages/MessageParts.tsx +++ b/client/src/components/Chat/Messages/MessageParts.tsx @@ -1,4 +1,5 @@ import React, { useMemo } from 'react'; +import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import type { TMessageContentParts } from 'librechat-data-provider'; import type { TMessageProps, TMessageIcon } from '~/common'; @@ -14,6 +15,7 @@ import { getHeaderModelName } from '~/components/Chat/Messages/ui/HeaderLabel'; import { revealOnRowHoverClasses, messageFooterClasses } from './styles'; import MessageRow from '~/components/Chat/Messages/ui/MessageRow'; import MessageIcon from '~/components/Chat/Messages/MessageIcon'; +import { showThinkingAtom } from '~/store/showThinking'; import Elapsed, { shouldShowElapsed } from './Elapsed'; import ContentParts from './Content/ContentParts'; import SiblingSwitch from './SiblingSwitch'; @@ -46,6 +48,7 @@ function MessageParts(props: TMessageProps) { } = useMessageHelpers(props, searchResults); const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); + const showThinking = useAtomValue(showThinkingAtom); const { messageId = null, isCreatedByUser } = message ?? {}; const name = useMemo(() => { @@ -173,6 +176,7 @@ function MessageParts(props: TMessageProps) { setSiblingIdx={setSiblingIdx} isCreatedByUser={message.isCreatedByUser} conversationId={conversation?.conversationId} + showThinking={showThinking} isLatestMessage={messageId === latestMessageId} content={message.content as Array} /> diff --git a/client/src/components/Chat/Subagents/SubagentActivity.tsx b/client/src/components/Chat/Subagents/SubagentActivity.tsx index f278bc1e9f..5811afacd7 100644 --- a/client/src/components/Chat/Subagents/SubagentActivity.tsx +++ b/client/src/components/Chat/Subagents/SubagentActivity.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import { useAtomValue } from 'jotai'; import { Button } from '@librechat/client'; import { ContentTypes } from 'librechat-data-provider'; import { CSSTransition } from 'react-transition-group'; @@ -17,6 +18,7 @@ import ContentParts from '~/components/Chat/Messages/Content/ContentParts'; import Container from '~/components/Chat/Messages/Content/Container'; import { EmptyText } from '~/components/Chat/Messages/Content/Parts'; import ScrollToBottom from '~/components/Messages/ScrollToBottom'; +import { showThinkingAtom } from '~/store/showThinking'; import { useChatSurface } from './surface'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; @@ -322,6 +324,7 @@ export function SubagentActivityContent({ onCancelControl?: (controlId: string) => void; }) { const localize = useLocalize(); + const showThinking = useAtomValue(showThinkingAtom); const isSubmitting = isLiveSubagentStatus(activity.status); const parts = useMemo(() => activity.items.map(toContentPart), [activity.items]); @@ -353,6 +356,7 @@ export function SubagentActivityContent({ isCreatedByUser={false} isLast isSubmitting={isSubmitting} + showThinking={showThinking} isLatestMessage={isSubmitting} /> ); diff --git a/client/src/components/Chat/Subagents/SubagentConversation.tsx b/client/src/components/Chat/Subagents/SubagentConversation.tsx index 3ddf673bf9..f39c6efe7b 100644 --- a/client/src/components/Chat/Subagents/SubagentConversation.tsx +++ b/client/src/components/Chat/Subagents/SubagentConversation.tsx @@ -1,4 +1,5 @@ import { useMemo, useState } from 'react'; +import { useAtomValue } from 'jotai'; import { ChevronDown, CornerDownRight, Radio } from 'lucide-react'; import { ContentTypes, EModelEndpoint } from 'librechat-data-provider'; import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger } from '@librechat/client'; @@ -13,6 +14,7 @@ import { messageFooterClasses } from '~/components/Chat/Messages/styles'; import MessageRow from '~/components/Chat/Messages/ui/MessageRow'; import { ElapsedTimer } from '~/components/Chat/Messages/Elapsed'; import MessageIcon from '~/components/Chat/Messages/MessageIcon'; +import { showThinkingAtom } from '~/store/showThinking'; import { useAgentsMapContext } from '~/Providers'; import { useChatSurface } from './surface'; import { useLocalize } from '~/hooks'; @@ -113,6 +115,7 @@ function ExternalEventTrigger({ } function TriggerMessage({ turn, fullWidth }: { turn: ChildConversationTurn; fullWidth: boolean }) { + const showThinking = useAtomValue(showThinkingAtom); const localize = useLocalize(); const label = localize(TRIGGER_LABELS[turn.trigger.kind]); const content = useMemo( @@ -152,6 +155,7 @@ function TriggerMessage({ turn, fullWidth }: { turn: ChildConversationTurn; full messageId={`${turn.taskId}:trigger`} conversationId={null} isCreatedByUser={true} + showThinking={showThinking} isLast={false} isSubmitting={false} isLatestMessage={false} diff --git a/client/src/components/Messages/ContentRender.tsx b/client/src/components/Messages/ContentRender.tsx index 51a9279e27..7d0138891a 100644 --- a/client/src/components/Messages/ContentRender.tsx +++ b/client/src/components/Messages/ContentRender.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo, memo } from 'react'; +import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import { Constants } from 'librechat-data-provider'; import type { TMessage, TMessageContentParts } from 'librechat-data-provider'; @@ -20,6 +21,7 @@ import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch'; import HoverButtons from '~/components/Chat/Messages/HoverButtons'; import MessageRow from '~/components/Chat/Messages/ui/MessageRow'; import MessageIcon from '~/components/Chat/Messages/MessageIcon'; +import { showThinkingAtom } from '~/store/showThinking'; import SubRow from '~/components/Chat/Messages/SubRow'; import store from '~/store'; @@ -106,6 +108,7 @@ const ContentRender = memo(function ContentRender({ chatContext, }); const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); + const showThinking = useAtomValue(showThinkingAtom); const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]); const isLast = useMemo( @@ -219,6 +222,7 @@ const ContentRender = memo(function ContentRender({ isSubmitting={isSubmitting} isCreatedByUser={msg.isCreatedByUser} createdAt={msg.createdAt ?? msg.clientTimestamp} + showThinking={showThinking} conversationId={conversation?.conversationId} content={msg.content as Array} /> diff --git a/client/src/hooks/Input/useAskAnswerMode.spec.ts b/client/src/hooks/Input/useAskAnswerMode.spec.ts index b6a6143f18..e4e2029165 100644 --- a/client/src/hooks/Input/useAskAnswerMode.spec.ts +++ b/client/src/hooks/Input/useAskAnswerMode.spec.ts @@ -16,6 +16,7 @@ const mockGetComposerText = jest.fn(() => 'answer from A'); const mockSetComposerText = jest.fn(); const mockSetDraft = jest.fn(); let mockSaveDrafts = false; +let mockAskStatus = 'idle'; let jotaiStore = createStore(); const JotaiWrapper = ({ children }: { children: React.ReactNode }) => @@ -27,7 +28,7 @@ const JotaiWrapper = ({ children }: { children: React.ReactNode }) => jest.mock('~/data-provider', () => ({ useGetMessagesByConvoId: jest.fn() })); jest.mock('~/components/Chat/Messages/Content/ApprovalContext', () => ({ - useAskSubmitStatus: () => ({ getAskStatus: () => 'idle' }), + useAskSubmitStatus: () => ({ getAskStatus: () => mockAskStatus }), useResumeSubmit: () => ({ submitAskAnswer: mockSubmitAskAnswer }), })); jest.mock('~/Providers', () => ({ @@ -66,8 +67,11 @@ describe('useAskAnswerMode', () => { beforeEach(() => { jest.clearAllMocks(); mockSaveDrafts = false; + mockAskStatus = 'idle'; jotaiStore = createStore(); mockGetComposerText.mockReturnValue('answer from A'); + mockSetComposerText.mockReset(); + mockSubmitAskAnswer.mockReset(); }); it('is active when a live ask is available', () => { @@ -233,6 +237,68 @@ describe('useAskAnswerMode', () => { }); }); + it.each(['expired', 'submitted', 'removed'])( + 'restores the released message when the question is %s', + (terminal) => { + let composerText = 'answer from A'; + mockGetComposerText.mockImplementation(() => composerText); + mockSetComposerText.mockImplementation((_name: string, text: string) => { + composerText = text; + }); + mockUseGetMessages.mockReturnValue({ data: liveAsk }); + const { result, rerender } = renderHook(() => useAskAnswerMode('conversation-1'), { + wrapper: JotaiWrapper, + }); + act(() => result.current.collapse()); + composerText = 'an ordinary unsent message'; + act(() => result.current.expand()); + expect(composerText).toBe('answer from A'); + + if (terminal === 'removed') { + mockUseGetMessages.mockReturnValue({ data: null }); + } else { + mockAskStatus = terminal; + } + rerender(); + + expect(result.current.active).toBe(false); + expect(composerText).toBe('an ordinary unsent message'); + expect(jotaiStore.get(releasedComposerTextAtom)).toEqual({}); + }, + ); + + it('preserves newer composer edits when an in-flight question expires', () => { + jotaiStore.set(releasedComposerTextAtom, { a1: 'an older message' }); + mockUseGetMessages.mockReturnValue({ data: liveAsk }); + const { rerender } = renderHook(() => useAskAnswerMode('conversation-1'), { + wrapper: JotaiWrapper, + }); + mockAskStatus = 'submitting'; + rerender(); + mockGetComposerText.mockReturnValue('newer message typed during submission'); + mockAskStatus = 'expired'; + rerender(); + + expect(mockSetComposerText).not.toHaveBeenCalled(); + expect(jotaiStore.get(releasedComposerTextAtom)).toEqual({}); + }); + + it('does not restore a departed conversation stash into a different composer', () => { + jotaiStore.set(releasedComposerTextAtom, { a1: 'message for conversation A' }); + mockUseGetMessages.mockReturnValue({ data: liveAsk }); + const { rerender } = renderHook(({ id }) => useAskAnswerMode(id), { + initialProps: { id: 'conversation-1' }, + wrapper: JotaiWrapper, + }); + mockUseGetMessages.mockReturnValue({ data: null }); + rerender({ id: 'conversation-2' }); + + expect(mockSetComposerText).not.toHaveBeenCalled(); + expect(jotaiStore.get(releasedComposerTextAtom)).toEqual({ + a1: 'message for conversation A', + }); + }); + 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 diff --git a/client/src/hooks/Input/useAskAnswerMode.ts b/client/src/hooks/Input/useAskAnswerMode.ts index 661bf140a7..c9fc612827 100644 --- a/client/src/hooks/Input/useAskAnswerMode.ts +++ b/client/src/hooks/Input/useAskAnswerMode.ts @@ -132,6 +132,63 @@ export default function useAskAnswerMode(conversationId?: string | null) { () => splitOtherOption(batchMode ? undefined : liveAsk?.question.options), [batchMode, liveAsk], ); + + const lastAnswerableRef = useRef<{ + conversationId: typeof conversationId; + actionId: string; + submitting: boolean; + composerText: string; + } | null>(null); + /** Expiration and server-side removal bypass the success callback. Recover + * the in-memory message on those exits too, without crossing conversations + * or replacing text edited while a resume was in flight. */ + useEffect(() => { + const previous = lastAnswerableRef.current; + if (answerable && liveAsk) { + const sameAction = + previous != null && + previous.conversationId === conversationId && + previous.actionId === liveAsk.actionId; + const submitting = status === 'submitting'; + lastAnswerableRef.current = { + conversationId, + actionId: liveAsk.actionId, + submitting, + composerText: + sameAction && submitting && previous.submitting + ? previous.composerText + : (formContext?.getValues('text') ?? ''), + }; + return; + } + lastAnswerableRef.current = null; + if (!previous || previous.conversationId !== conversationId) { + return; + } + const released = releasedComposerText[previous.actionId]; + if (released == null) { + return; + } + if (!previous.submitting || formContext?.getValues('text') === previous.composerText) { + formContext?.setValue('text', released); + } + setReleasedComposerText((current) => { + if (current[previous.actionId] == null) { + return current; + } + const next = { ...current }; + delete next[previous.actionId]; + return next; + }); + }, [ + answerable, + conversationId, + liveAsk, + status, + formContext, + releasedComposerText, + setReleasedComposerText, + ]); const answerText = liveAsk != null ? (answerDrafts[liveAsk.actionId] ?? '') : ''; const setAnswerText = useCallback( (text: string) => { @@ -284,10 +341,10 @@ export default function useAskAnswerMode(conversationId?: string | null) { * flight). * * The selection/composer cleanup runs ONLY after the resume is accepted (in - * `submitAskAnswer`'s success path): a failed resume — the 16k answer-cap - * 400, an expired action, a network error — leaves `status` re-answerable, - * so wiping the composer (the user's only copy of a free-form answer) up - * front would lose it. The composer only resets when its text was consumed + * `submitAskAnswer`'s success path): a retryable failed resume — the 16k + * answer-cap 400 or a network error — must preserve the user's free-form + * answer. Terminal exits separately restore any released normal message. + * The composer only resets when its text was consumed * by the answer or when the draft machinery will restore the stashed * conversation draft; with drafts disabled and an option-click answer the * typed text is left alone.