Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions apps/sim/app/chat/components/message/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { memo, useMemo, useState } from 'react'
import { Check, Copy, File as FileIcon, FileText, Image as ImageIcon } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import { safeRenderValue } from '@/lib/core/utils/safe-render'
import {
ChatFileDownload,
ChatFileDownloadAll,
Expand Down Expand Up @@ -50,9 +51,14 @@ export const ClientChatMessage = memo(
return typeof message.content === 'object' && message.content !== null
}, [message.content])

// Since tool calls are now handled via SSE events and stored in message.toolCalls,
// we can use the content directly without parsing
const cleanTextContent = message.content
// Safely convert content to a renderable string to prevent React error #31
// when workflow nodes return structured objects (e.g. { text, type })
// Use safeRenderValue for all content types — it extracts .text from
// structured objects like { text, type } and falls back to JSON.stringify
const cleanTextContent = useMemo(
() => safeRenderValue(message.content),
[message.content]
)

const content =
message.type === 'user' ? (
Expand Down Expand Up @@ -161,14 +167,14 @@ export const ClientChatMessage = memo(
)}

{/* Only render message bubble if there's actual text content (not just file count message) */}
{message.content && !String(message.content).startsWith('Sent') && (
{cleanTextContent && !cleanTextContent.startsWith('Sent') && (
<div className='flex justify-end'>
<div className='max-w-[80%] rounded-3xl bg-[#F4F4F4] px-4 py-3 dark:bg-gray-600'>
<div className='whitespace-pre-wrap break-words text-base text-gray-800 leading-relaxed dark:text-gray-100'>
{isJsonObject ? (
<pre>{JSON.stringify(message.content, null, 2)}</pre>
<pre>{cleanTextContent}</pre>
) : (
<span>{message.content as string}</span>
<span>{cleanTextContent}</span>
)}
</div>
</div>
Expand All @@ -184,11 +190,9 @@ export const ClientChatMessage = memo(
<div>
<div className='break-words text-base'>
{isJsonObject ? (
<pre className='text-gray-800 dark:text-gray-100'>
{JSON.stringify(cleanTextContent, null, 2)}
</pre>
<pre className='text-gray-800 dark:text-gray-100'>{cleanTextContent}</pre>
) : (
<EnhancedMarkdownRenderer content={cleanTextContent as string} />
<EnhancedMarkdownRenderer content={cleanTextContent} />
)}
</div>
</div>
Expand All @@ -208,11 +212,7 @@ export const ClientChatMessage = memo(
<button
className='text-muted-foreground transition-colors hover:bg-muted'
onClick={() => {
const contentToCopy =
typeof cleanTextContent === 'string'
? cleanTextContent
: JSON.stringify(cleanTextContent, null, 2)
navigator.clipboard.writeText(contentToCopy)
navigator.clipboard.writeText(cleanTextContent)
setIsCopied(true)
setTimeout(() => setIsCopied(false), 2000)
}}
Expand Down
11 changes: 6 additions & 5 deletions apps/sim/app/chat/hooks/use-chat-streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { safeRenderValue } from '@/lib/core/utils/safe-render'
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
import type { ChatFile, ChatMessage } from '@/app/chat/components/message/message'
import { CHAT_ERROR_MESSAGES } from '@/app/chat/constants'
Expand Down Expand Up @@ -84,12 +85,12 @@ export function useChatStreaming() {

// Only modify if the last message is from the assistant (as expected)
if (lastMessage && lastMessage.type === 'assistant') {
// Append a note that the response was stopped
// Safely convert content to string before concatenation to prevent
// React error #31 when content is a structured object
const existingContent = safeRenderValue(lastMessage.content)
const updatedContent =
lastMessage.content +
(lastMessage.content
? '\n\n_Response stopped by user._'
: '_Response stopped by user._')
existingContent +
(existingContent ? '\n\n_Response stopped by user._' : '_Response stopped by user._')

return [
...prev.slice(0, -1),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useMemo } from 'react'
import { safeRenderValue } from '@/lib/core/utils/safe-render'
import { StreamingIndicator } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming'

interface ChatAttachment {
Expand Down Expand Up @@ -94,10 +95,7 @@ const WordWrap = ({ text }: { text: string }) => {
*/
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 || '')
return safeRenderValue(message.content)
}, [message.content])

const handleAttachmentClick = (attachment: ChatAttachment) => {
Expand Down
82 changes: 82 additions & 0 deletions apps/sim/lib/core/utils/safe-render.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { safeRenderValue } from '@/lib/core/utils/safe-render'

describe('safeRenderValue', () => {
it('returns empty string for null', () => {
expect(safeRenderValue(null)).toBe('')
})

it('returns empty string for undefined', () => {
expect(safeRenderValue(undefined)).toBe('')
})

it('returns string values unchanged', () => {
expect(safeRenderValue('hello world')).toBe('hello world')
})

it('returns empty string for empty string input', () => {
expect(safeRenderValue('')).toBe('')
})

it('converts numbers to string', () => {
expect(safeRenderValue(42)).toBe('42')
expect(safeRenderValue(0)).toBe('0')
expect(safeRenderValue(-1.5)).toBe('-1.5')
})

it('converts booleans to string', () => {
expect(safeRenderValue(true)).toBe('true')
expect(safeRenderValue(false)).toBe('false')
})

it('extracts text from {text, type} content block objects', () => {
expect(safeRenderValue({ text: 'Hello from AI', type: 'text' })).toBe('Hello from AI')
})

it('extracts text from {text} objects without type', () => {
expect(safeRenderValue({ text: 'Some text' })).toBe('Some text')
})

it('joins text from arrays of content blocks', () => {
const contentArray = [
{ text: 'Hello ', type: 'text' },
{ text: 'world', type: 'text' },
]
expect(safeRenderValue(contentArray)).toBe('Hello world')
})

it('handles arrays with mixed content types', () => {
const mixedArray = [
{ text: 'Text part', type: 'text' },
{ type: 'tool_use', id: '123', name: 'search' },
]
const result = safeRenderValue(mixedArray)
expect(result).toContain('Text part')
expect(result).toContain('tool_use')
})

it('handles string arrays', () => {
expect(safeRenderValue(['hello', 'world'])).toBe('helloworld')
})

it('JSON-stringifies plain objects without text property', () => {
const obj = { key: 'value', nested: { a: 1 } }
expect(safeRenderValue(obj)).toBe(JSON.stringify(obj, null, 2))
})

it('JSON-stringifies empty objects', () => {
expect(safeRenderValue({})).toBe('{}')
})

it('handles empty arrays', () => {
expect(safeRenderValue([])).toBe('[]')
})

it('does not extract text when text property is not a string', () => {
const obj = { text: 42, type: 'number' }
expect(safeRenderValue(obj)).toBe(JSON.stringify(obj, null, 2))
})
})
67 changes: 67 additions & 0 deletions apps/sim/lib/core/utils/safe-render.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* Safely converts a value to a string suitable for rendering in JSX.
*
* Prevents React error #31 ("Objects are not valid as a React child") by
* ensuring that structured objects (e.g. `{ text, type }` content blocks
* returned by LLM providers) are converted to a displayable string instead
* of being passed directly as React children.
*
* @param value - The value to convert. Can be a string, number, boolean,
* null, undefined, array, or object.
* @returns A string representation safe for rendering in JSX.
*/
export function safeRenderValue(value: unknown): string {
if (value === null || value === undefined) {
return ''
}

if (typeof value === 'string') {
return value
}

if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}

if (typeof value === 'object') {
// Handle content block objects like { text, type } from LLM providers
// by extracting the text property when available
if (
!Array.isArray(value) &&
'text' in value &&
typeof (value as Record<string, unknown>).text === 'string'
) {
return (value as Record<string, unknown>).text as string
}

// Handle arrays of content blocks (e.g. Anthropic's content array)
if (Array.isArray(value)) {
const textParts = value
.map((item) => {
if (typeof item === 'string') return item
if (
item &&
typeof item === 'object' &&
'text' in item &&
typeof item.text === 'string'
) {
return item.text
}
return JSON.stringify(item)
})
.filter(Boolean)

if (textParts.length > 0) {
return textParts.join('')
}
}

try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}

return String(value)
}
11 changes: 5 additions & 6 deletions apps/sim/stores/chat/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
import { v4 as uuidv4 } from 'uuid'
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { safeRenderValue } from '@/lib/core/utils/safe-render'
import type { ChatMessage, ChatState } from './types'
import { MAX_CHAT_HEIGHT, MAX_CHAT_WIDTH, MIN_CHAT_HEIGHT, MIN_CHAT_WIDTH } from './utils'

Expand Down Expand Up @@ -213,12 +214,10 @@ export const useChatStore = create<ChatState>()(

const newMessages = state.messages.map((message) => {
if (message.id === messageId) {
const newContent =
typeof message.content === 'string'
? message.content + content
: message.content
? String(message.content) + content
: content
// Safely convert existing content to string before concatenation
// to prevent React error #31 when content is a structured object
const existingContent = safeRenderValue(message.content)
const newContent = existingContent + content
logger.debug('[ChatStore] Updated message content', {
messageId,
oldLength: typeof message.content === 'string' ? message.content.length : 0,
Expand Down