mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-29 10:12:39 +00:00
Fix terminal answer handoff and encoded edit previews
This commit is contained in:
parent
318da18d28
commit
a23e8828ae
13 changed files with 235 additions and 34 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
const entry = coercedEdit as Record<string, unknown>;
|
||||
const oldText = textValue(entry.old_text);
|
||||
const newText = textValue(entry.new_text);
|
||||
return oldText || newText ? { oldText, newText } : undefined;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<FileAuthoringCall
|
||||
toolName="edit_file"
|
||||
initialProgress={1}
|
||||
isSubmitting={false}
|
||||
args={{
|
||||
file_path: 'workspace/demo/SKILL.md',
|
||||
edits: JSON.stringify([
|
||||
{ old_text: 'description: Old behavior', new_text: 'description: New behavior' },
|
||||
{ old_text: 'status: draft', new_text: 'status: published' },
|
||||
]),
|
||||
}}
|
||||
output="Updated workspace/demo/SKILL.md with 2 exact replacements."
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<FileAuthoringCall
|
||||
toolName="edit_file"
|
||||
initialProgress={1}
|
||||
isSubmitting={false}
|
||||
args={{
|
||||
file_path: 'workspace/demo/SKILL.md',
|
||||
edits: [
|
||||
JSON.stringify({ old_text: 'enabled: false', new_text: 'enabled: true' }),
|
||||
JSON.stringify({ old_text: 'tier: free', new_text: 'tier: pro' }),
|
||||
],
|
||||
}}
|
||||
output="Updated workspace/demo/SKILL.md with 2 exact replacements."
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('enabled: false')).toBeInTheDocument();
|
||||
expect(screen.getByText('enabled: true')).toBeInTheDocument();
|
||||
expect(screen.getByText('tier: free')).toBeInTheDocument();
|
||||
expect(screen.getByText('tier: pro')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement | null>(null);
|
||||
const cancelLayoutReconcileRef = useRef<(() => void) | null>(null);
|
||||
const retainedForPendingApprovalRef = useRef(false);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@ const baseProps = {
|
|||
isSubmitting: false,
|
||||
isLatestMessage: false,
|
||||
isCreatedByUser: false,
|
||||
showThinking: false,
|
||||
content: [],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -259,6 +259,7 @@ describe('ToolCallGroup image hoisting', () => {
|
|||
parts,
|
||||
isSubmitting: false,
|
||||
isLast: false,
|
||||
showThinking: false,
|
||||
lastContentIdx: 1,
|
||||
renderPart: (_p: TMessageContentParts, idx: number) => (
|
||||
<div data-testid={`inner-${idx}`} key={idx}>
|
||||
|
|
|
|||
|
|
@ -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<TMessageContentParts | undefined>}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<TMessageContentParts[]>(
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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<TMessageContentParts | undefined>}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue