Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 })
const cleanTextContent = useMemo(() => {
if (isJsonObject) {
return JSON.stringify(message.content, null, 2)
}
return safeRenderValue(message.content)
}, [message.content, isJsonObject])
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistency: isJsonObject branch uses JSON.stringify instead of safeRenderValue

When isJsonObject is true (i.e., when a structured content block like { text: "Hello", type: "text" } is returned), cleanTextContent is computed via JSON.stringify, which displays the raw JSON structure in the <pre> block rather than extracting the .text field.

This is inconsistent with chat-message.tsx in the workspace surface, which uses safeRenderValue unconditionally and correctly extracts the .text field. As a result, the same content block displays differently across the two chat surfaces: raw JSON in public chat vs. extracted text in workspace chat.

Consider using safeRenderValue for both branches:

Suggested change
const cleanTextContent = useMemo(() => {
if (isJsonObject) {
return JSON.stringify(message.content, null, 2)
}
return safeRenderValue(message.content)
}, [message.content, isJsonObject])
// Safely convert content to a renderable string to prevent React error #31
// when LLM returns structured objects (e.g. { text, type })
const cleanTextContent = useMemo(() => {
return safeRenderValue(message.content)
}, [message.content])

If displaying formatted JSON for non-content-block objects is intentional, the isJsonObject flag can still control the <pre> vs <span> rendering decision without affecting text extraction.

Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

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