mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
fix: preserve tool run and answer state
This commit is contained in:
parent
3ff2780f44
commit
73b5162f39
12 changed files with 362 additions and 57 deletions
|
|
@ -62,6 +62,14 @@ export default function AskUserQuestion({
|
|||
/** Live pause: share the hook's checked set so the composer's Enter and
|
||||
* this card submit exactly what the card displays. */
|
||||
const checkedIndices = isLivePause ? answerMode.checked : localChecked;
|
||||
const answerValue = isLivePause ? answerMode.answerText : answer;
|
||||
const setAnswerValue = (value: string) => {
|
||||
if (isLivePause) {
|
||||
answerMode.setAnswerText(value);
|
||||
return;
|
||||
}
|
||||
setAnswer(value);
|
||||
};
|
||||
const toggleIndex = (index: number) => {
|
||||
if (isLivePause) {
|
||||
answerMode.toggleChecked(index);
|
||||
|
|
@ -72,7 +80,7 @@ export default function AskUserQuestion({
|
|||
);
|
||||
};
|
||||
|
||||
const trimmed = answer.trim();
|
||||
const trimmed = answerValue.trim();
|
||||
const canSubmit = multiSelect
|
||||
? checkedIndices.length > 0 || trimmed.length > 0
|
||||
: trimmed.length > 0;
|
||||
|
|
@ -177,13 +185,13 @@ export default function AskUserQuestion({
|
|||
)}
|
||||
|
||||
<TextareaAutosize
|
||||
value={answer}
|
||||
value={answerValue}
|
||||
disabled={locked}
|
||||
onChange={(e) => setAnswer(e.target.value)}
|
||||
onChange={(e) => setAnswerValue(e.target.value)}
|
||||
minRows={2}
|
||||
maxRows={12}
|
||||
placeholder={otherLabel ?? localize('com_ui_your_answer')}
|
||||
className="w-full resize-none rounded-lg border border-border-light bg-surface-chat px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none"
|
||||
className="w-full resize-none rounded-lg border border-border-light bg-surface-chat px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary"
|
||||
aria-label={localize('com_ui_your_answer')}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { ChevronRight, Users } from 'lucide-react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { useRecoilCallback, useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import type { TAttachment, TMessage, TMessageContentParts } from 'librechat-data-provider';
|
||||
import type { SubagentRun } from '~/store/subagents';
|
||||
import {
|
||||
|
|
@ -60,6 +60,7 @@ export default function SubagentCall({
|
|||
|
||||
const runOverride: SubagentRun = {
|
||||
toolCallId,
|
||||
isSubmitting,
|
||||
args,
|
||||
output,
|
||||
attachments,
|
||||
|
|
@ -78,6 +79,7 @@ export default function SubagentCall({
|
|||
if (!toolCallId) return;
|
||||
const signature = [
|
||||
initialProgress,
|
||||
isSubmitting,
|
||||
typeof args === 'string' ? args : JSON.stringify(args ?? null),
|
||||
output ?? '',
|
||||
persistedContent?.length ?? -1,
|
||||
|
|
@ -91,23 +93,31 @@ export default function SubagentCall({
|
|||
lastWrittenRef.current = signature;
|
||||
setRuns((prev) => ({
|
||||
...(prev ?? {}),
|
||||
[toolCallId]: { toolCallId, args, output, attachments, persistedContent, initialProgress },
|
||||
[toolCallId]: {
|
||||
toolCallId,
|
||||
isSubmitting,
|
||||
args,
|
||||
output,
|
||||
attachments,
|
||||
persistedContent,
|
||||
initialProgress,
|
||||
},
|
||||
}));
|
||||
}, [toolCallId, args, output, attachments, persistedContent, initialProgress, setRuns]);
|
||||
}, [
|
||||
toolCallId,
|
||||
isSubmitting,
|
||||
args,
|
||||
output,
|
||||
attachments,
|
||||
persistedContent,
|
||||
initialProgress,
|
||||
setRuns,
|
||||
]);
|
||||
|
||||
/** Auto-open the panel when a run first streams in — mirrors
|
||||
* `ToolArtifactCard`. `isSubmitting` is captured once at first render so a
|
||||
* history mount (page load, back-navigation) never steals focus. */
|
||||
const readInitialIsSubmitting = useRecoilCallback(
|
||||
({ snapshot }) =>
|
||||
() =>
|
||||
snapshot.getLoadable(store.isSubmittingFamily(0)).valueMaybe() ?? false,
|
||||
[],
|
||||
);
|
||||
const mountedDuringStreamRef = useRef<boolean | null>(null);
|
||||
if (mountedDuringStreamRef.current === null) {
|
||||
mountedDuringStreamRef.current = readInitialIsSubmitting();
|
||||
}
|
||||
const mountedDuringStreamRef = useRef(isSubmitting);
|
||||
const autoFocusedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!toolCallId || autoFocusedRef.current || !mountedDuringStreamRef.current) return;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import { SUBAGENT_TICKER_THROTTLE_MS } from '../subagentShared';
|
|||
import SubagentCall from '../SubagentCall';
|
||||
import store from '~/store';
|
||||
|
||||
const mockMCPServerNames: string[] = [];
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize:
|
||||
() =>
|
||||
|
|
@ -66,8 +68,7 @@ jest.mock('~/components/Share/MessageIcon', () => ({
|
|||
}));
|
||||
|
||||
jest.mock('~/hooks/MCP', () => {
|
||||
const mcpServerNames: string[] = [];
|
||||
return { useMCPServerNames: () => mcpServerNames };
|
||||
return { useMCPServerNames: () => mockMCPServerNames };
|
||||
});
|
||||
|
||||
jest.mock('~/utils', () => ({
|
||||
|
|
@ -78,6 +79,7 @@ jest.mock('~/utils', () => ({
|
|||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
mockMCPServerNames.length = 0;
|
||||
});
|
||||
|
||||
function foldEvents(events: SubagentUpdateEvent[]): {
|
||||
|
|
@ -273,6 +275,42 @@ describe('SubagentCall — inline preview', () => {
|
|||
expect(screen.getAllByText('Writing:')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('preserves a configured MCP server boundary in the live ticker', () => {
|
||||
mockMCPServerNames.push('Google_mcp_Workspace');
|
||||
renderWithState({
|
||||
toolCallId: 'call_mcp_ticker',
|
||||
initialProgress: 0.3,
|
||||
isSubmitting: true,
|
||||
progress: progressFromEvents({
|
||||
subagentRunId: 'run_a',
|
||||
subagentType: 'self',
|
||||
status: 'run_step',
|
||||
events: [
|
||||
{
|
||||
runId: 'p',
|
||||
subagentRunId: 'run_a',
|
||||
subagentType: 'self',
|
||||
phase: 'run_step',
|
||||
data: {
|
||||
stepDetails: {
|
||||
type: 'tool_calls',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'c1',
|
||||
name: 'search_documents_mcp_Google_mcp_Workspace',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
timestamp: '',
|
||||
} as SubagentUpdateEvent,
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Google_mcp_Workspace')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a one-line result summary from the final text once finished', () => {
|
||||
renderWithState({
|
||||
toolCallId: 'call_summary',
|
||||
|
|
@ -412,4 +450,16 @@ describe('SubagentCall — panel open contract', () => {
|
|||
});
|
||||
expect(screen.getByTestId('current-run-id')).toHaveTextContent('');
|
||||
});
|
||||
|
||||
it('does NOT auto-focus a historical run during an unrelated stream', () => {
|
||||
renderWithState({
|
||||
toolCallId: 'call_unrelated_stream',
|
||||
initialProgress: 0.3,
|
||||
isSubmitting: false,
|
||||
initializeStreaming: true,
|
||||
progress: null,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('current-run-id')).toHaveTextContent('');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { SubagentUpdatePhase } from 'librechat-data-provider';
|
|||
import type { SubagentTickerLine } from '~/utils/subagentContent';
|
||||
import type { SubagentRun } from '~/store/subagents';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
import { useMCPServerNames } from '~/hooks/MCP';
|
||||
import { parseToolName } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
|
@ -150,11 +151,10 @@ export function useSubagentRunView(
|
|||
const registered = useRecoilValue(store.subagentRunByIdSelector(toolCallId));
|
||||
const run = runOverride ?? registered;
|
||||
const progress = useRecoilValue(store.subagentProgressByToolCallId(toolCallId));
|
||||
/** The inline card passes its own `isSubmitting` prop (the message's stream
|
||||
* state); the panel, rendered outside the message tree, omits it and falls
|
||||
* back to the conversation's global submit atom. */
|
||||
const familyIsSubmitting = useRecoilValue(store.isSubmittingFamily(0));
|
||||
const isSubmitting = isSubmittingOverride ?? familyIsSubmitting;
|
||||
/** Submission state belongs to this run. The detached panel cannot use the
|
||||
* conversation-wide atom: another response may be streaming while a
|
||||
* historical stopped run is selected. */
|
||||
const isSubmitting = isSubmittingOverride ?? run?.isSubmitting ?? false;
|
||||
const agentsMap = useAgentsMapContext();
|
||||
|
||||
const initialProgress = run?.initialProgress ?? 0;
|
||||
|
|
@ -263,11 +263,13 @@ function ToolNameBadge({ name }: { name: string }): JSX.Element {
|
|||
function ToolIdentifier({
|
||||
rawName,
|
||||
localize,
|
||||
mcpServerNames,
|
||||
}: {
|
||||
rawName: string;
|
||||
localize: ReturnType<typeof useLocalize>;
|
||||
mcpServerNames: readonly string[];
|
||||
}): JSX.Element {
|
||||
const parsed = parseToolName(rawName);
|
||||
const parsed = parseToolName(rawName, mcpServerNames);
|
||||
if (parsed.mcpServer) {
|
||||
return (
|
||||
<span className="inline-flex min-w-0 shrink items-baseline gap-1">
|
||||
|
|
@ -291,6 +293,7 @@ function ToolIdentifier({
|
|||
*/
|
||||
export function TickerLineView({ line }: { line: SubagentTickerLine }): JSX.Element {
|
||||
const localize = useLocalize();
|
||||
const mcpServerNames = useMCPServerNames();
|
||||
if (line.kind === 'writing' || line.kind === 'reasoning') {
|
||||
const prefix =
|
||||
line.kind === 'writing'
|
||||
|
|
@ -312,7 +315,7 @@ export function TickerLineView({ line }: { line: SubagentTickerLine }): JSX.Elem
|
|||
{line.toolNames.map((name, i) => (
|
||||
<span key={`${i}-${name}`} className="flex min-w-0 items-baseline gap-1">
|
||||
{i > 0 && <span className="shrink-0 text-text-tertiary">,</span>}
|
||||
<ToolIdentifier rawName={name} localize={localize} />
|
||||
<ToolIdentifier rawName={name} localize={localize} mcpServerNames={mcpServerNames} />
|
||||
</span>
|
||||
))}
|
||||
{line.argsSnippet && (
|
||||
|
|
@ -325,7 +328,11 @@ export function TickerLineView({ line }: { line: SubagentTickerLine }): JSX.Elem
|
|||
if (line.kind === 'tool_complete') {
|
||||
return (
|
||||
<span className="flex w-full items-baseline gap-1 overflow-hidden whitespace-nowrap text-text-secondary">
|
||||
<ToolIdentifier rawName={line.toolName} localize={localize} />
|
||||
<ToolIdentifier
|
||||
rawName={line.toolName}
|
||||
localize={localize}
|
||||
mcpServerNames={mcpServerNames}
|
||||
/>
|
||||
<span className="shrink-0 text-text-tertiary">→</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{line.outputSnippet ?? localize('com_ui_subagent_ticker_tool_done')}
|
||||
|
|
|
|||
|
|
@ -283,9 +283,9 @@ export default function ToolCallGroup({
|
|||
/** For a single-tool group, lead with the tool's own (capitalized) label
|
||||
* instead of the generic "Used 1 tool — name", which reads awkwardly. */
|
||||
const singleToolLabel = useMemo(() => {
|
||||
const raw = getToolDisplayLabel(toolMetadata[0]?.name ?? '', localize);
|
||||
const raw = getToolDisplayLabel(toolMetadata[0]?.name ?? '', localize, mcpServerNames);
|
||||
return raw ? raw.charAt(0).toUpperCase() + raw.slice(1) : '';
|
||||
}, [toolMetadata, localize]);
|
||||
}, [toolMetadata, localize, mcpServerNames]);
|
||||
|
||||
const autoExpand = useRecoilValue(store.autoExpandTools);
|
||||
/** A labeled activity block is summarized by its header, so it collapses
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import AskUserQuestion from '../AskUserQuestion';
|
||||
|
||||
const mockSubmitAnswer = jest.fn();
|
||||
const mockSetAnswerText = jest.fn();
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
size: _size,
|
||||
variant: _variant,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { size?: string; variant?: string }) => (
|
||||
<button type="button" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
TextareaAutosize: ({
|
||||
minRows: _minRows,
|
||||
maxRows: _maxRows,
|
||||
...props
|
||||
}: React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
}) => <textarea {...props} />,
|
||||
TooltipAnchor: ({ render }: { render: React.ReactNode }) => render,
|
||||
}));
|
||||
|
||||
jest.mock('lucide-react', () => ({
|
||||
ChevronUp: () => <span />,
|
||||
MessageCircleQuestion: () => <span />,
|
||||
TriangleAlert: () => <span />,
|
||||
}));
|
||||
|
||||
jest.mock('../ApprovalContext', () => ({
|
||||
useAskSubmitStatus: () => ({ getAskStatus: () => 'idle' }),
|
||||
useResumeSubmit: () => ({ submitAskAnswer: jest.fn() }),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/Input/useAskAnswerMode', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
popoverVisible: false,
|
||||
collapsed: true,
|
||||
expand: jest.fn(),
|
||||
liveAsk: { actionId: 'action-1' },
|
||||
checked: [0],
|
||||
toggleChecked: jest.fn(),
|
||||
submitOption: jest.fn(),
|
||||
submitAnswer: mockSubmitAnswer,
|
||||
answerText: 'carried free-form answer',
|
||||
setAnswerText: mockSetAnswerText,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('~/components/Chat/ask/options', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="ask-options" />,
|
||||
}));
|
||||
|
||||
jest.mock('~/Providers/ChatContext', () => {
|
||||
const ReactModule = jest.requireActual<typeof React>('react');
|
||||
return {
|
||||
ChatContext: ReactModule.createContext({
|
||||
conversation: { conversationId: 'conversation-1' },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
com_ui_your_answer: 'Your answer',
|
||||
com_ui_skip: 'Skip',
|
||||
com_ui_submit: 'Submit',
|
||||
};
|
||||
return translations[key] ?? key;
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('~/utils', () => ({
|
||||
cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '),
|
||||
}));
|
||||
|
||||
jest.mock('~/utils/approval', () => ({
|
||||
ASK_USER_DECLINED_ANSWER: 'User declined to answer',
|
||||
splitOtherOption: (options?: Array<{ label: string; value: string }>) => ({
|
||||
choices: options ?? [],
|
||||
otherLabel: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('AskUserQuestion', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('submits checked options together with free text carried from the composer', () => {
|
||||
render(
|
||||
<AskUserQuestion
|
||||
actionId="action-1"
|
||||
question={{
|
||||
question: 'Choose a source',
|
||||
multiSelect: true,
|
||||
options: [{ label: 'Public data', value: 'public' }],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('textbox', { name: 'Your answer' })).toHaveValue(
|
||||
'carried free-form answer',
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Submit' }));
|
||||
|
||||
expect(mockSubmitAnswer).toHaveBeenCalledWith(['public', 'carried free-form answer']);
|
||||
});
|
||||
});
|
||||
|
|
@ -7,6 +7,8 @@ import { scheduleMessageContentLayoutReconcile } from '~/hooks';
|
|||
import ToolCallGroup from '../ToolCallGroup';
|
||||
import { ToolAuthWarning } from '../auth';
|
||||
|
||||
const mockMCPServerNames: string[] = [];
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string, values?: Record<string | number, string>) => {
|
||||
if (key === 'com_ui_ran_n_actions') {
|
||||
|
|
@ -79,10 +81,9 @@ jest.mock('~/hooks', () => ({
|
|||
}));
|
||||
|
||||
jest.mock('~/hooks/MCP', () => {
|
||||
const mcpServerNames: string[] = [];
|
||||
return {
|
||||
useMCPIconMap: () => new Map(),
|
||||
useMCPServerNames: () => mcpServerNames,
|
||||
useMCPServerNames: () => mockMCPServerNames,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -111,7 +112,14 @@ jest.mock('~/utils/approval', () => ({
|
|||
|
||||
jest.mock('~/utils', () => ({
|
||||
cn: (...classes: Array<string | false | null | undefined>) => classes.filter(Boolean).join(' '),
|
||||
getToolDisplayLabel: (name: string) => {
|
||||
getToolDisplayLabel: (name: string, _localize: unknown, knownServerNames?: readonly string[]) => {
|
||||
const configuredServer = knownServerNames?.find((server) => name.endsWith(`_mcp_${server}`));
|
||||
if (configuredServer) {
|
||||
return configuredServer;
|
||||
}
|
||||
if (name.includes('_mcp_')) {
|
||||
return name.slice(name.lastIndexOf('_mcp_') + '_mcp_'.length);
|
||||
}
|
||||
if (
|
||||
['execute_code', 'bash_tool', 'run_tools_with_code', 'run_tools_with_bash'].includes(name)
|
||||
) {
|
||||
|
|
@ -254,6 +262,7 @@ describe('ToolCallGroup image hoisting', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
mockScheduleMessageContentLayoutReconcile.mockClear();
|
||||
mockMCPServerNames.length = 0;
|
||||
});
|
||||
|
||||
it('renders an AttachmentGroup outside the collapsible container with all attachments', () => {
|
||||
|
|
@ -653,6 +662,22 @@ describe('ToolCallGroup image hoisting', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('preserves a configured MCP server boundary in a single-tool label', () => {
|
||||
mockMCPServerNames.push('Google_mcp_Workspace');
|
||||
renderGroup({
|
||||
...baseProps,
|
||||
parts: [
|
||||
{
|
||||
part: makePart('mcp-1', 'result', 'search_documents_mcp_Google_mcp_Workspace'),
|
||||
idx: 0,
|
||||
},
|
||||
],
|
||||
lastContentIdx: 0,
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: /^Google_mcp_Workspace$/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('summarizes repeated completed web searches as an outcome and count', () => {
|
||||
const searchParts = Array.from({ length: 9 }, (_, idx) => ({
|
||||
part: makePart(`w${idx}`, 'result', 'web_search'),
|
||||
|
|
|
|||
|
|
@ -86,12 +86,13 @@ function RunIdProbe() {
|
|||
return <div data-testid="run-id">{runId ?? ''}</div>;
|
||||
}
|
||||
|
||||
function renderPanel(run: SubagentRun) {
|
||||
function renderPanel(run: SubagentRun, conversationSubmitting = false) {
|
||||
return render(
|
||||
<RecoilRoot
|
||||
initializeState={({ set }) => {
|
||||
set(store.subagentRunsState, { [run.toolCallId]: run });
|
||||
set(store.currentSubagentRunId, run.toolCallId);
|
||||
set(store.isSubmittingFamily(0), conversationSubmitting);
|
||||
}}
|
||||
>
|
||||
<RunIdProbe />
|
||||
|
|
@ -159,4 +160,22 @@ describe('SubagentPanel', () => {
|
|||
// self-spawn with no agent name → the localized "Agent" title
|
||||
expect(screen.getByText('Agent')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps a historical stopped run stopped during an unrelated stream', async () => {
|
||||
renderPanel(
|
||||
{
|
||||
toolCallId: 'panel_stopped',
|
||||
args: { subagent_type: 'self' },
|
||||
output: '',
|
||||
initialProgress: 0.4,
|
||||
isSubmitting: false,
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Stopped')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Running')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,23 +18,25 @@ jest.mock('react-router-dom', () => ({
|
|||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'@librechat/client',
|
||||
() => {
|
||||
const React = jest.requireActual<typeof import('react')>('react');
|
||||
return {
|
||||
OGDialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
|
||||
open ? React.createElement('div', null, children) : null,
|
||||
OGDialogContent: ({ children }: { children: ReactNode }) =>
|
||||
React.createElement('div', null, children),
|
||||
Spinner: () => React.createElement('div', { 'data-testid': 'spinner' }),
|
||||
useToastContext: () => ({
|
||||
showToast: mockShowToast,
|
||||
}),
|
||||
};
|
||||
},
|
||||
{ virtual: true },
|
||||
);
|
||||
jest.mock('@librechat/client', () => {
|
||||
const React = jest.requireActual<typeof import('react')>('react');
|
||||
const ReactDOM = jest.requireActual<typeof import('react-dom')>('react-dom');
|
||||
return {
|
||||
OGDialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
|
||||
open
|
||||
? ReactDOM.createPortal(
|
||||
React.createElement('div', null, children),
|
||||
globalThis.document.body,
|
||||
)
|
||||
: null,
|
||||
OGDialogContent: ({ children }: { children: ReactNode }) =>
|
||||
React.createElement('div', { role: 'dialog', 'data-state': 'open' }, children),
|
||||
Spinner: () => React.createElement('div', { 'data-testid': 'spinner' }),
|
||||
useToastContext: () => ({
|
||||
showToast: mockShowToast,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetFileConfig: ({ select }: { select?: (data: FileConfigInput | undefined) => unknown }) => ({
|
||||
|
|
@ -71,7 +73,13 @@ jest.mock('~/utils', () => ({
|
|||
}));
|
||||
|
||||
function getFileInput(container: HTMLElement): HTMLInputElement {
|
||||
const input = container.querySelector<HTMLInputElement>('input[type="file"]');
|
||||
/** Prefer the local render container used by the lightweight dialog mock,
|
||||
* then the active portal. Exiting Headless UI portals can leave older file
|
||||
* inputs in `document.body`; a document-wide first/last match is unstable. */
|
||||
const selector = 'input[type="file"][accept=".zip,.skill,.md"]';
|
||||
const input =
|
||||
container.querySelector<HTMLInputElement>(selector) ??
|
||||
document.querySelector<HTMLInputElement>(`[role="dialog"][data-state="open"] ${selector}`);
|
||||
if (input == null) {
|
||||
throw new Error('Upload input was not rendered');
|
||||
}
|
||||
|
|
@ -106,6 +114,18 @@ describe('UploadSkillDialog', () => {
|
|||
expect(screen.getByText('File size must not exceed 1.06 MB')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('targets the current upload input when an exiting portal still has one', () => {
|
||||
const staleInput = document.createElement('input');
|
||||
staleInput.type = 'file';
|
||||
staleInput.accept = '.zip,.skill,.md';
|
||||
document.body.appendChild(staleInput);
|
||||
|
||||
const { container } = render(<UploadSkillDialog isOpen={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
expect(getFileInput(container)).not.toBe(staleInput);
|
||||
staleInput.remove();
|
||||
});
|
||||
|
||||
it('rejects files above the configured skill import limit before upload', () => {
|
||||
const { container } = render(<UploadSkillDialog isOpen={true} setIsOpen={mockSetIsOpen} />);
|
||||
const file = new File([new Uint8Array(1024 * 1024 + 1)], 'too-large.skill', {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ const mockResetComposer = jest.fn();
|
|||
const mockGetComposerText = jest.fn(() => 'answer from A');
|
||||
const mockSetSelected = jest.fn();
|
||||
const mockSetChecked = jest.fn();
|
||||
const mockSetAnswerDraft = jest.fn();
|
||||
let mockSaveDrafts = false;
|
||||
|
||||
jest.mock('~/data-provider', () => ({ useGetMessagesByConvoId: jest.fn() }));
|
||||
|
|
@ -16,7 +17,10 @@ jest.mock('~/Providers', () => ({
|
|||
getValues: mockGetComposerText,
|
||||
}),
|
||||
}));
|
||||
jest.mock('~/utils', () => ({ getAskAnswerDraftId: (id: string) => `draft-${id}` }));
|
||||
jest.mock('~/utils', () => ({
|
||||
getAskAnswerDraftId: (id: string) => `draft-${id}`,
|
||||
morphTransition: (update: () => void) => update(),
|
||||
}));
|
||||
jest.mock('recoil', () => ({
|
||||
atom: (cfg: unknown) => cfg,
|
||||
useRecoilState: (state: { key?: string }) => {
|
||||
|
|
@ -26,13 +30,16 @@ jest.mock('recoil', () => ({
|
|||
if (state.key === 'askAnswerModeChecked') {
|
||||
return [[], mockSetChecked];
|
||||
}
|
||||
if (state.key === 'askAnswerModeText') {
|
||||
return [{ actionId: null, text: '' }, mockSetAnswerDraft];
|
||||
}
|
||||
return [[], jest.fn()];
|
||||
},
|
||||
useRecoilValue: () => mockSaveDrafts,
|
||||
}));
|
||||
jest.mock('~/store', () => ({ __esModule: true, default: { saveDrafts: 'saveDrafts' } }));
|
||||
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useGetMessagesByConvoId } from '~/data-provider';
|
||||
import { findLiveAskUserQuestion } from '~/utils/approval';
|
||||
import useAskAnswerMode from './useAskAnswerMode';
|
||||
|
|
@ -96,6 +103,18 @@ describe('useAskAnswerMode', () => {
|
|||
expect(result.current.liveAsk).toBeNull();
|
||||
});
|
||||
|
||||
it('carries the composer answer into shared card state when collapsed', () => {
|
||||
mockUseGetMessages.mockReturnValue({ data: liveAsk });
|
||||
const { result } = renderHook(() => useAskAnswerMode('conversation-1'));
|
||||
|
||||
act(() => result.current.collapse());
|
||||
|
||||
expect(mockSetAnswerDraft).toHaveBeenCalledWith({
|
||||
actionId: 'a1',
|
||||
text: 'answer from A',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let a delayed answer success clear the composer or selection after navigation', () => {
|
||||
let finishAnswer: (() => void) | undefined;
|
||||
mockUseGetMessages.mockReturnValue({ data: liveAsk });
|
||||
|
|
|
|||
|
|
@ -40,6 +40,12 @@ const askAnswerCheckedAtom = atom<number[]>({
|
|||
default: [],
|
||||
});
|
||||
|
||||
/** Free-form answer handed between the composer and the in-message card. */
|
||||
const askAnswerTextAtom = atom<{ actionId: string | null; text: string }>({
|
||||
key: 'askAnswerModeText',
|
||||
default: { actionId: null, text: '' },
|
||||
});
|
||||
|
||||
/**
|
||||
* First-class "answer mode" for a live `ask_user_question` pause. Clicking an
|
||||
* option submits it immediately (multi-select clicks toggle instead, confirmed
|
||||
|
|
@ -73,6 +79,7 @@ export default function useAskAnswerMode(conversationId?: string | null) {
|
|||
const [collapsedIds, setCollapsedIds] = useRecoilState(collapsedAskActionsAtom);
|
||||
const [selected, setSelected] = useRecoilState(askAnswerSelectionAtom);
|
||||
const [checked, setChecked] = useRecoilState(askAnswerCheckedAtom);
|
||||
const [answerDraft, setAnswerDraft] = useRecoilState(askAnswerTextAtom);
|
||||
const saveDrafts = useRecoilValue<boolean>(store.saveDrafts);
|
||||
const { submitAskAnswer } = useResumeSubmit();
|
||||
/** Recoil-backed so the lock/status works from the composer, which renders
|
||||
|
|
@ -143,6 +150,15 @@ export default function useAskAnswerMode(conversationId?: string | null) {
|
|||
() => splitOtherOption(liveAsk?.question.options),
|
||||
[liveAsk],
|
||||
);
|
||||
const answerText = answerDraft.actionId === liveAsk?.actionId ? answerDraft.text : '';
|
||||
const setAnswerText = useCallback(
|
||||
(text: string) => {
|
||||
if (liveAsk) {
|
||||
setAnswerDraft({ actionId: liveAsk.actionId, text });
|
||||
}
|
||||
},
|
||||
[liveAsk, setAnswerDraft],
|
||||
);
|
||||
|
||||
/** Selection state is per-question: a new pause must never inherit a stale
|
||||
* highlight (or checks) whose Enter would submit the previous question's
|
||||
|
|
@ -158,13 +174,15 @@ export default function useAskAnswerMode(conversationId?: string | null) {
|
|||
* which morphTransition's synchronous flush requires. */
|
||||
const collapse = useCallback(() => {
|
||||
if (liveAsk) {
|
||||
morphTransition(() =>
|
||||
const composerAnswer = formContext?.getValues('text') ?? answerText;
|
||||
morphTransition(() => {
|
||||
setAnswerDraft({ actionId: liveAsk.actionId, text: composerAnswer });
|
||||
setCollapsedIds((prev) =>
|
||||
prev.includes(liveAsk.actionId) ? prev : [...prev, liveAsk.actionId],
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
}
|
||||
}, [liveAsk, setCollapsedIds]);
|
||||
}, [liveAsk, formContext, answerText, setAnswerDraft, setCollapsedIds]);
|
||||
|
||||
const expand = useCallback(() => {
|
||||
if (liveAsk) {
|
||||
|
|
@ -227,6 +245,11 @@ export default function useAskAnswerMode(conversationId?: string | null) {
|
|||
}
|
||||
setSelected(null);
|
||||
setChecked([]);
|
||||
setAnswerDraft((current) =>
|
||||
current.actionId === submittedActionId
|
||||
? { actionId: submittedActionId, text: '' }
|
||||
: current,
|
||||
);
|
||||
if (
|
||||
(consumedComposerText || (wasActive && saveDrafts)) &&
|
||||
currentScope.formContext?.getValues('text') === submittedComposerText
|
||||
|
|
@ -247,6 +270,7 @@ export default function useAskAnswerMode(conversationId?: string | null) {
|
|||
submitAskAnswer,
|
||||
setSelected,
|
||||
setChecked,
|
||||
setAnswerDraft,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -464,6 +488,8 @@ export default function useAskAnswerMode(conversationId?: string | null) {
|
|||
setSelected,
|
||||
checked,
|
||||
toggleChecked,
|
||||
answerText,
|
||||
setAnswerText,
|
||||
canSubmit,
|
||||
submit,
|
||||
submitOption,
|
||||
|
|
|
|||
|
|
@ -62,6 +62,9 @@ export const subagentProgressByToolCallId = atomFamily<SubagentProgress | null,
|
|||
*/
|
||||
export interface SubagentRun {
|
||||
toolCallId: string;
|
||||
/** Whether this run's parent message is actively streaming. Kept with the
|
||||
* run so detached consumers never infer status from an unrelated stream. */
|
||||
isSubmitting?: boolean;
|
||||
args?: string | Record<string, unknown>;
|
||||
output?: string | null;
|
||||
attachments?: TAttachment[];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue