mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 06:52:47 +00:00
⌨️ refactor: Clarify Bash Command Drafting State (#12963)
This commit is contained in:
parent
f839a447e1
commit
25a4556aee
5 changed files with 156 additions and 10 deletions
|
|
@ -8,7 +8,7 @@ import useToolCallState from './useToolCallState';
|
|||
import useLazyHighlight from './useLazyHighlight';
|
||||
import { ERROR_PATTERNS } from './ExecuteCode';
|
||||
import { AttachmentGroup } from './Attachment';
|
||||
import parseJsonField from './parseJsonField';
|
||||
import parseJsonField, { areToolCallArgsComplete } from './parseJsonField';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
|
|
@ -27,6 +27,7 @@ export default function BashCall({
|
|||
}) {
|
||||
const localize = useLocalize();
|
||||
const command = useMemo(() => parseJsonField(args, 'command'), [args]);
|
||||
const isWritingCommand = !command || !areToolCallArgsComplete(args);
|
||||
|
||||
const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError, hasOutput } =
|
||||
useToolCallState(initialProgress, isSubmitting, output, !!command);
|
||||
|
|
@ -51,7 +52,11 @@ export default function BashCall({
|
|||
<ProgressText
|
||||
progress={progress}
|
||||
onClick={toggleCode}
|
||||
inProgressText={localize('com_ui_running_command')}
|
||||
inProgressText={
|
||||
isWritingCommand
|
||||
? localize('com_ui_writing_command')
|
||||
: localize('com_ui_running_command')
|
||||
}
|
||||
finishedText={
|
||||
cancelled ? localize('com_ui_cancelled') : localize('com_ui_command_finished')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
import React from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import BashCall from '../BashCall';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize:
|
||||
() =>
|
||||
(key: string): string => {
|
||||
const translations: Record<string, string> = {
|
||||
com_ui_writing_command: 'Writing command',
|
||||
com_ui_running_command: 'Running command',
|
||||
com_ui_command_finished: 'Finished running',
|
||||
com_ui_cancelled: 'Cancelled',
|
||||
com_ui_copy_code: 'Copy code',
|
||||
};
|
||||
return translations[key] ?? key;
|
||||
},
|
||||
useProgress: (initialProgress: number) => initialProgress,
|
||||
useExpandCollapse: (isExpanded: boolean) => ({
|
||||
style: {
|
||||
display: 'grid',
|
||||
gridTemplateRows: isExpanded ? '1fr' : '0fr',
|
||||
opacity: isExpanded ? 1 : 0,
|
||||
},
|
||||
ref: { current: null },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('~/components/Chat/Messages/Content/ProgressText', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
progress,
|
||||
inProgressText,
|
||||
finishedText,
|
||||
}: {
|
||||
progress: number;
|
||||
inProgressText: string;
|
||||
finishedText: string;
|
||||
}) => <div data-testid="progress-text">{progress < 1 ? inProgressText : finishedText}</div>,
|
||||
}));
|
||||
|
||||
jest.mock('~/components/Messages/Content/CopyButton', () => ({
|
||||
__esModule: true,
|
||||
default: ({ label }: { label?: string }) => <button type="button">{label}</button>,
|
||||
}));
|
||||
|
||||
jest.mock('~/components/Messages/Content/LangIcon', () => ({
|
||||
__esModule: true,
|
||||
default: () => <span data-testid="lang-icon" />,
|
||||
}));
|
||||
|
||||
jest.mock('../Attachment', () => ({
|
||||
AttachmentGroup: () => <div data-testid="attachment-group" />,
|
||||
}));
|
||||
|
||||
jest.mock('../useLazyHighlight', () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
jest.mock('copy-to-clipboard', () => jest.fn());
|
||||
|
||||
jest.mock('~/utils', () => ({
|
||||
cn: (...classes: Array<string | false | null | undefined>) => classes.filter(Boolean).join(' '),
|
||||
}));
|
||||
|
||||
const renderBashCall = (args?: string | Record<string, unknown>) =>
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<BashCall initialProgress={0.1} isSubmitting={true} args={args} output="" />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
describe('BashCall status text', () => {
|
||||
it.each([undefined, '', '{"command":"sleep 10"', '{"command":"sleep 10","timeout":'])(
|
||||
'shows "Writing command" while args are missing or incomplete: %s',
|
||||
(args) => {
|
||||
renderBashCall(args);
|
||||
expect(screen.getByTestId('progress-text')).toHaveTextContent('Writing command');
|
||||
expect(screen.queryByText('Running command')).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps showing "Writing command" for partial JSON even after the command field is visible', () => {
|
||||
renderBashCall('{"command":"sleep 10","incomplete":');
|
||||
expect(screen.getByTestId('progress-text')).toHaveTextContent('Writing command');
|
||||
expect(screen.getByText(/sleep 10/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(['{"command":"sleep 10"}', { command: 'sleep 10' }])(
|
||||
'shows "Running command" once command args are complete: %s',
|
||||
(args) => {
|
||||
renderBashCall(args);
|
||||
expect(screen.getByTestId('progress-text')).toHaveTextContent('Running command');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import parseJsonField from '../parseJsonField';
|
||||
import parseJsonField, { areToolCallArgsComplete } from '../parseJsonField';
|
||||
|
||||
describe('parseJsonField', () => {
|
||||
describe('object args', () => {
|
||||
|
|
@ -101,3 +101,25 @@ describe('parseJsonField', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('areToolCallArgsComplete', () => {
|
||||
it.each([undefined, '', ' ', '{"command":"ls"', '{"command":"ls","timeout":'])(
|
||||
'returns false for missing or partial args %s',
|
||||
(args) => {
|
||||
expect(areToolCallArgsComplete(args)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[{ command: 'ls -la' }],
|
||||
['{"command":"ls -la"}'],
|
||||
['{"command":"sleep 1","timeout":1000}'],
|
||||
])('returns true for complete object args %s', (args) => {
|
||||
expect(areToolCallArgsComplete(args)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for complete non-object JSON', () => {
|
||||
expect(areToolCallArgsComplete('"ls -la"')).toBe(false);
|
||||
expect(areToolCallArgsComplete('[]')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,22 @@
|
|||
type ToolCallArgs = string | Record<string, unknown> | undefined;
|
||||
|
||||
export function areToolCallArgsComplete(args: ToolCallArgs): boolean {
|
||||
if (typeof args === 'object' && args !== null) {
|
||||
return true;
|
||||
}
|
||||
if (typeof args !== 'string' || args.trim().length === 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(args);
|
||||
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Extracts a string field from tool call args, handling object, JSON string, and partial-JSON fallback. */
|
||||
export default function parseJsonField(
|
||||
args: string | Record<string, unknown> | undefined,
|
||||
field: string,
|
||||
): string {
|
||||
export default function parseJsonField(args: ToolCallArgs, field: string): string {
|
||||
if (typeof args === 'object' && args !== null) {
|
||||
return String(args[field] ?? '');
|
||||
}
|
||||
|
|
@ -20,7 +34,13 @@ export default function parseJsonField(
|
|||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
return match[1].replace(/\\(.)/g, (_, c: string) =>
|
||||
c === 'n' ? '\n' : c === '"' ? '"' : c === '\\' ? '\\' : `\\${c}`,
|
||||
);
|
||||
return match[1].replace(/\\(.)/g, (_, c: string) => {
|
||||
if (c === 'n') {
|
||||
return '\n';
|
||||
}
|
||||
if (c === '"' || c === '\\') {
|
||||
return c;
|
||||
}
|
||||
return `\\${c}`;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -853,6 +853,7 @@
|
|||
"com_ui_command_finished": "Finished running",
|
||||
"com_ui_command_placeholder": "Optional: Enter a command for the prompt or name will be used",
|
||||
"com_ui_command_usage_placeholder": "Select a Prompt by command or name",
|
||||
"com_ui_writing_command": "Writing command",
|
||||
"com_ui_complete": "Complete!",
|
||||
"com_ui_complete_setup": "Complete Setup",
|
||||
"com_ui_concise": "Concise",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue