From 0fd92fe59e75d4b43416d58a39c72ea732a8a9d2 Mon Sep 17 00:00:00 2001 From: Patel230 Date: Thu, 8 Jan 2026 16:07:54 +0530 Subject: [PATCH 1/7] fix: handle object rendering in chat UI (#2725) --- .../w/[workflowId]/components/chat/chat.tsx | 17 +- .../components/chat-message/chat-message.tsx | 11 +- apps/sim/lib/core/utils/format-output.test.ts | 277 +++++++++++++++++ apps/sim/lib/core/utils/format-output.ts | 278 ++++++++++++++++++ 4 files changed, 563 insertions(+), 20 deletions(-) create mode 100644 apps/sim/lib/core/utils/format-output.test.ts create mode 100644 apps/sim/lib/core/utils/format-output.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index 0ad5e42f07..5fc1afd475 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -2,6 +2,7 @@ import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' +import { formatOutputForWorkflow } from '@/lib/core/utils/format-output' import { AlertCircle, ArrowDownToLine, @@ -156,20 +157,8 @@ const extractOutputFromLogs = (logs: BlockLog[] | undefined, outputId: string): return output } -/** - * Formats output content for display in chat - * @param output - Output value to format (string, object, or other) - * @returns Formatted string, markdown code block for objects, or empty string - */ -const formatOutputContent = (output: unknown): string => { - if (typeof output === 'string') { - return output - } - if (output && typeof output === 'object') { - return `\`\`\`json\n${JSON.stringify(output, null, 2)}\n\`\`\`` - } - return '' -} +// Use shared utility for formatting output - removed duplicate code +const formatOutputContent = formatOutputForWorkflow /** * Represents a field in the start block's input format configuration diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx index 2a01d630a4..1769314adf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx @@ -1,5 +1,6 @@ import { useMemo } from 'react' import { StreamingIndicator } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming' +import { formatOutputForChat } from '@/lib/core/utils/format-output' interface ChatAttachment { id: string @@ -93,12 +94,10 @@ const WordWrap = ({ text }: { text: string }) => { * Renders a chat message with optional file attachments */ export function ChatMessage({ message }: ChatMessageProps) { - const formattedContent = useMemo(() => { - if (typeof message.content === 'object' && message.content !== null) { - return JSON.stringify(message.content, null, 2) - } - return String(message.content || '') - }, [message.content]) + const formattedContent = useMemo( + () => formatOutputForChat(message.content), + [message.content] + ) const handleAttachmentClick = (attachment: ChatAttachment) => { const validDataUrl = attachment.dataUrl?.trim() diff --git a/apps/sim/lib/core/utils/format-output.test.ts b/apps/sim/lib/core/utils/format-output.test.ts new file mode 100644 index 0000000000..17c0925732 --- /dev/null +++ b/apps/sim/lib/core/utils/format-output.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, it, vi } from 'vitest' +import { + formatOutputForDisplay, + formatOutputForChat, + formatOutputForWorkflow, + formatOutputRaw, + formatOutputSafe, + isOutputSafe +} from './format-output' + +describe('format-output utilities', () => { + describe('formatOutputForDisplay', () => { + // Basic types + it('handles null and undefined', () => { + expect(formatOutputForDisplay(null)).toBe('') + expect(formatOutputForDisplay(undefined)).toBe('') + }) + + it('handles primitive types', () => { + expect(formatOutputForDisplay('hello')).toBe('hello') + expect(formatOutputForDisplay(123)).toBe('123') + expect(formatOutputForDisplay(true)).toBe('true') + expect(formatOutputForDisplay(false)).toBe('false') + expect(formatOutputForDisplay(0)).toBe('0') + expect(formatOutputForDisplay(BigInt(999))).toBe('999') + }) + + // Object with text property + it('extracts text from objects with text property', () => { + expect(formatOutputForDisplay({ text: 'Hello World', type: 'response' })).toBe('Hello World') + expect(formatOutputForDisplay({ text: ' spaced ', other: 'data' })).toBe('spaced') + }) + + // Nested objects + it('handles deeply nested text properties', () => { + const nested = { + data: { + response: { + message: { + content: 'Deep text' + } + } + } + } + expect(formatOutputForDisplay(nested)).toBe('Deep text') + }) + + // Arrays + it('handles arrays of text objects', () => { + const arr = [ + { text: 'Line 1' }, + { text: 'Line 2' }, + { content: 'Line 3' } + ] + expect(formatOutputForDisplay(arr)).toBe('Line 1 Line 2 Line 3') + }) + + it('handles mixed arrays', () => { + const mixed = [ + 'String', + { text: 'Object text' }, + 123, + null, + { message: 'Message text' } + ] + expect(formatOutputForDisplay(mixed)).toBe('String Object text 123 Message text') + }) + + // Special objects + it('handles Date objects', () => { + const date = new Date('2024-01-01T00:00:00Z') + expect(formatOutputForDisplay(date)).toBe('2024-01-01T00:00:00.000Z') + }) + + it('handles Error objects', () => { + const error = new Error('Test error') + expect(formatOutputForDisplay(error)).toBe('Test error') + }) + + it('handles RegExp objects', () => { + const regex = /test.*pattern/gi + expect(formatOutputForDisplay(regex)).toBe('/test.*pattern/gi') + }) + + // Circular references + it('handles circular references', () => { + const obj: any = { a: 1 } + obj.self = obj + const result = formatOutputForDisplay(obj, { mode: 'raw' }) + expect(result).toContain('[Circular]') + expect(result).not.toThrow() + }) + + // Large arrays + it('handles large arrays gracefully', () => { + const bigArray = new Array(2000).fill('item') + const result = formatOutputForDisplay(bigArray) + expect(result).toContain('[Large Array: 2000 items]') + }) + + // Binary data + it('handles Buffer data', () => { + const buffer = Buffer.from('Hello Buffer') + expect(formatOutputForDisplay(buffer)).toBe('Hello Buffer') + + const binaryBuffer = Buffer.from([0xFF, 0xFE, 0x00, 0x01]) + expect(formatOutputForDisplay(binaryBuffer)).toBe('[Binary Data]') + }) + + // Truncation + it('truncates long strings when specified', () => { + const longText = 'x'.repeat(10000) + const result = formatOutputForDisplay(longText, { maxLength: 100, truncate: true }) + expect(result.length).toBeLessThan(150) + expect(result).toContain('... [truncated]') + }) + + // Whitespace handling + it('preserves whitespace when requested', () => { + const spaced = 'Line 1\n\nLine 2\t\tTabbed' + expect(formatOutputForDisplay(spaced, { preserveWhitespace: true })) + .toBe('Line 1\n\nLine 2\t\tTabbed') + expect(formatOutputForDisplay(spaced, { preserveWhitespace: false })) + .toBe('Line 1 Line 2 Tabbed') + }) + + // Mode-specific formatting + it('formats correctly for different modes', () => { + const obj = { data: 'test' } + + const chatFormat = formatOutputForDisplay(obj, { mode: 'chat' }) + expect(chatFormat).toContain('test') + + const workflowFormat = formatOutputForDisplay(obj, { mode: 'workflow' }) + expect(workflowFormat).toMatch(/```json/) + + const rawFormat = formatOutputForDisplay(obj, { mode: 'raw' }) + expect(rawFormat).toBe('{"data":"test"}') + }) + + // Edge cases + it('handles objects with toString method', () => { + const customObj = { + toString() { + return 'Custom String' + } + } + expect(formatOutputForDisplay(customObj)).toBe('Custom String') + }) + + it('handles undefined and function properties', () => { + const obj = { + func: () => console.log('test'), + undef: undefined, + sym: Symbol('test') + } + const result = formatOutputForDisplay(obj, { mode: 'raw' }) + expect(result).toContain('[Function]') + expect(result).toContain('[undefined]') + expect(result).toContain('[Symbol]') + }) + }) + + describe('specialized formatters', () => { + it('formatOutputForChat limits length', () => { + const longText = 'x'.repeat(10000) + const result = formatOutputForChat(longText) + expect(result.length).toBeLessThanOrEqual(5100) // 5000 + truncation message + }) + + it('formatOutputForWorkflow wraps in code block', () => { + const obj = { test: 'data' } + const result = formatOutputForWorkflow(obj) + expect(result).toMatch(/^```json/) + expect(result).toMatch(/```$/) + }) + + it('formatOutputRaw preserves everything', () => { + const text = ' \n\t spaced \n\t ' + const result = formatOutputRaw(text) + expect(result).toBe(text) + }) + }) + + describe('security features', () => { + it('detects unsafe content', () => { + expect(isOutputSafe('')).toBe(false) + expect(isOutputSafe('javascript:void(0)')).toBe(false) + expect(isOutputSafe('
')).toBe(false) + expect(isOutputSafe('