diff --git a/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx b/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx index 9e4c4fb71a..6bec1fab8c 100644 --- a/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx @@ -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({ ({ + useLocalize: + () => + (key: string): string => { + const translations: Record = { + 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; + }) =>
{progress < 1 ? inProgressText : finishedText}
, +})); + +jest.mock('~/components/Messages/Content/CopyButton', () => ({ + __esModule: true, + default: ({ label }: { label?: string }) => , +})); + +jest.mock('~/components/Messages/Content/LangIcon', () => ({ + __esModule: true, + default: () => , +})); + +jest.mock('../Attachment', () => ({ + AttachmentGroup: () =>
, +})); + +jest.mock('../useLazyHighlight', () => ({ + __esModule: true, + default: () => null, +})); + +jest.mock('copy-to-clipboard', () => jest.fn()); + +jest.mock('~/utils', () => ({ + cn: (...classes: Array) => classes.filter(Boolean).join(' '), +})); + +const renderBashCall = (args?: string | Record) => + render( + + + , + ); + +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'); + }, + ); +}); diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/parseJsonField.test.ts b/client/src/components/Chat/Messages/Content/Parts/__tests__/parseJsonField.test.ts index 4d042387e2..0d02d9b836 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/parseJsonField.test.ts +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/parseJsonField.test.ts @@ -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); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/Parts/parseJsonField.ts b/client/src/components/Chat/Messages/Content/Parts/parseJsonField.ts index 986744cf3b..cf4f9a3aa4 100644 --- a/client/src/components/Chat/Messages/Content/Parts/parseJsonField.ts +++ b/client/src/components/Chat/Messages/Content/Parts/parseJsonField.ts @@ -1,8 +1,22 @@ +type ToolCallArgs = string | Record | 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 | 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}`; + }); } diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index efeaced119..1299774c3a 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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",