Skip to content
Merged
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
3 changes: 2 additions & 1 deletion apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { generateId } from '@sim/utils/id'
import {
AGENT_STREAM_PROTOCOL_HEADER,
AGENT_STREAM_PROTOCOL_V1,
CHAT_OUTPUT_PROTOCOL_V1,
} from '@/lib/workflows/streaming/agent-stream-protocol'
import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar'
import {
Expand Down Expand Up @@ -236,7 +237,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
[AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1,
[AGENT_STREAM_PROTOCOL_HEADER]: `${AGENT_STREAM_PROTOCOL_V1}, ${CHAT_OUTPUT_PROTOCOL_V1}`,
},
body: JSON.stringify(payload),
credentials: 'same-origin',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ChatFileDownload } from '@/app/(interfaces)/chat/components/message/components/file-download'
import type { ChatFile } from '@/app/(interfaces)/chat/components/message/message'

const imageFile: ChatFile = {
id: 'file-image',
name: 'generated.png',
key: 'execution/generated.png',
url: 'https://files.example.com/generated.png',
size: 3,
type: 'image/png',
base64: 'YWJj',
}

const mounts: Array<() => void> = []

function renderFile(file: ChatFile): HTMLDivElement {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const container = document.createElement('div')
const root = createRoot(container)
act(() => root.render(<ChatFileDownload file={file} />))
mounts.push(() => act(() => root.unmount()))
return container
}

afterEach(() => {
while (mounts.length) mounts.pop()?.()
vi.restoreAllMocks()
})

describe('ChatFileDownload', () => {
it('previews returned image bytes inline without requiring a workspace session', () => {
const container = renderFile(imageFile)
const image = container.querySelector('img')
expect(image?.getAttribute('src')).toBe('data:image/png;base64,YWJj')
expect(image?.alt).toBe('generated.png')
expect(container.querySelector('button')?.textContent).toContain('generated.png')
})

it('uses the file URL when inline bytes are unavailable', () => {
const container = renderFile({ ...imageFile, base64: undefined })
expect(container.querySelector('img')?.getAttribute('src')).toBe(imageFile.url)
})

it('uses the canonical serve route for unsafe file URLs', () => {
const container = renderFile({ ...imageFile, base64: undefined, url: 'javascript:alert(1)' })
expect(container.querySelector('img')?.getAttribute('src')).toBe(
'/api/files/serve/execution%2Fgenerated.png?context=execution'
)
})

it('keeps a download available when an image preview fails', () => {
const container = renderFile(imageFile)
act(() => container.querySelector('img')!.dispatchEvent(new Event('error')))
expect(container.querySelector('img')).toBeNull()
expect(container.querySelector('button')?.textContent).toContain('generated.png')
})

it('renders documents as downloads without an image preview', () => {
const container = renderFile({ ...imageFile, name: 'report.pdf', type: 'application/pdf' })
expect(container.querySelector('img')).toBeNull()
expect(container.querySelector('button')?.textContent).toContain('report.pdf')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ function isImageFile(mimeType: string): boolean {
}

function getFileUrl(file: ChatFile): string {
if (file.base64) return `data:${file.type};base64,${file.base64}`
if (isSafeHttpUrl(file.url)) return file.url
return `/api/files/serve/${encodeURIComponent(file.key)}?context=${file.context || 'execution'}`
}

Expand All @@ -76,6 +78,8 @@ async function triggerDownload(url: string, filename: string): Promise<void> {

export function ChatFileDownload({ file }: ChatFileDownloadProps) {
const [isDownloading, setIsDownloading] = useState(false)
const [failedPreviewUrl, setFailedPreviewUrl] = useState<string | null>(null)
const fileUrl = getFileUrl(file)

const handleDownload = async () => {
if (isDownloading) return
Expand Down Expand Up @@ -109,25 +113,36 @@ export function ChatFileDownload({ file }: ChatFileDownloadProps) {
}

return (
<Button
variant='default'
onClick={handleDownload}
disabled={isDownloading}
className='group flex h-auto w-[200px] items-center gap-2 rounded-lg px-3 py-2'
>
<div className='flex size-8 shrink-0 items-center justify-center'>{renderIcon()}</div>
<div className='min-w-0 flex-1 text-left'>
<div className='w-[100px] truncate text-xs'>{file.name}</div>
<div className='text-[var(--text-muted)] text-micro'>{formatFileSize(file.size)}</div>
</div>
<div className='shrink-0'>
{isDownloading ? (
<Loader className='size-3.5' animate />
) : (
<Download className='size-3.5 opacity-0 transition-opacity group-hover:opacity-100' />
)}
</div>
</Button>
<div className='flex max-w-full flex-col items-start gap-2'>
{isImageFile(file.type) && failedPreviewUrl !== fileUrl && (
<img
src={fileUrl}
alt={file.name}
loading='lazy'
className='-outline-offset-1 max-h-[480px] max-w-full rounded-lg object-contain outline outline-1 outline-black/10 dark:outline-white/10'
onError={() => setFailedPreviewUrl(fileUrl)}
/>
)}
<Button
variant='default'
onClick={handleDownload}
disabled={isDownloading}
className='group flex h-auto w-[200px] items-center gap-2 rounded-lg px-3 py-2'
>
<div className='flex size-8 shrink-0 items-center justify-center'>{renderIcon()}</div>
<div className='min-w-0 flex-1 text-left'>
<div className='w-[100px] truncate text-xs'>{file.name}</div>
<div className='text-[var(--text-muted)] text-micro'>{formatFileSize(file.size)}</div>
</div>
<div className='shrink-0'>
{isDownloading ? (
<Loader className='size-3.5' animate />
) : (
<Download className='size-3.5 opacity-0 transition-opacity group-hover:opacity-100' />
)}
</div>
</Button>
</div>
)
}

Expand Down
35 changes: 35 additions & 0 deletions apps/sim/app/(interfaces)/chat/components/message/message.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,41 @@ describe('ClientChatMessage thinking chrome (Step 6)', () => {
}
})

it('renders no message row or copy action for empty assistant output', () => {
const { container, unmount } = renderMessage({
id: 'empty-output',
type: 'assistant',
content: '',
files: [],
timestamp: new Date(),
})
mounts.push(unmount)
expect(container.innerHTML).toBe('')
})

it('does not show a copy action for a file-only response', () => {
const { container, unmount } = renderMessage({
id: 'file-output',
type: 'assistant',
content: '',
files: [
{
id: 'file-1',
name: 'image.png',
url: '/image.png',
key: 'image.png',
size: 3,
type: 'image/png',
},
],
timestamp: new Date(),
})
mounts.push(unmount)
expect(container.querySelector('[data-message-id]')).not.toBeNull()
expect(container.querySelector('[data-testid="answer"]')).toBeNull()
expect(container.textContent).not.toContain('Copy to clipboard')
})

it('does not show thinking chrome when thinking is absent or empty', () => {
const without = renderMessage({
id: '1',
Expand Down
37 changes: 24 additions & 13 deletions apps/sim/app/(interfaces)/chat/components/message/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface ChatFile {
size: number
type: string
context?: string
base64?: string
}

/** Chat surface tool chip — the shared lifecycle chip plus its block id. */
Expand Down Expand Up @@ -100,11 +101,13 @@ function openAttachmentPreview(name: string, dataUrl: string): void {
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000)
}

interface ClientChatMessageProps {
message: ChatMessage
}

export const ClientChatMessage = memo(function ClientChatMessage({
message,
}: {
message: ChatMessage
}) {
}: ClientChatMessageProps) {
const [isCopied, setIsCopied] = useState(false)

const isJsonObject = typeof message.content === 'object' && message.content !== null
Expand All @@ -113,6 +116,12 @@ export const ClientChatMessage = memo(function ClientChatMessage({
const cleanTextContent = message.content
const hasThinking = typeof message.thinking === 'string' && message.thinking.length > 0
const hasToolCalls = Array.isArray(message.toolCalls) && message.toolCalls.length > 0
const hasContent = isJsonObject || Boolean((message.content as string).trim())
const hasFiles = Boolean(message.files?.length)

if (message.type === 'assistant' && !hasContent && !hasFiles && !hasThinking && !hasToolCalls) {
return null
}

const content =
message.type === 'user' ? (
Expand Down Expand Up @@ -238,15 +247,17 @@ export const ClientChatMessage = memo(function ClientChatMessage({
isStreaming={message.isToolStreaming}
/>
)}
<div className='break-words text-base'>
{isJsonObject ? (
<pre className='text-[var(--text-primary)]'>
{JSON.stringify(cleanTextContent, null, 2)}
</pre>
) : (
<MarkdownRenderer content={cleanTextContent as string} />
)}
</div>
{hasContent && (
<div className='break-words text-base'>
{isJsonObject ? (
<pre className='text-[var(--text-primary)]'>
{JSON.stringify(cleanTextContent, null, 2)}
</pre>
) : (
<MarkdownRenderer content={cleanTextContent as string} />
)}
</div>
)}
</div>
{message.files && message.files.length > 0 && (
<div className='flex flex-wrap gap-2'>
Expand All @@ -257,7 +268,7 @@ export const ClientChatMessage = memo(function ClientChatMessage({
)}
{message.type === 'assistant' && !isJsonObject && !message.isInitialMessage && (
<div className='flex items-center justify-start space-x-2'>
{!message.isStreaming && (
{!message.isStreaming && hasContent && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
Expand Down
Loading
Loading