Skip to content

Commit 62d922f

Browse files
committed
fix(chat): render deployed file outputs inline
1 parent b2b9eed commit 62d922f

11 files changed

Lines changed: 549 additions & 127 deletions

File tree

apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { generateId } from '@sim/utils/id'
66
import {
77
AGENT_STREAM_PROTOCOL_HEADER,
88
AGENT_STREAM_PROTOCOL_V1,
9+
CHAT_OUTPUT_PROTOCOL_V1,
910
} from '@/lib/workflows/streaming/agent-stream-protocol'
1011
import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar'
1112
import {
@@ -236,7 +237,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
236237
headers: {
237238
'Content-Type': 'application/json',
238239
'X-Requested-With': 'XMLHttpRequest',
239-
[AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1,
240+
[AGENT_STREAM_PROTOCOL_HEADER]: `${AGENT_STREAM_PROTOCOL_V1}, ${CHAT_OUTPUT_PROTOCOL_V1}`,
240241
},
241242
body: JSON.stringify(payload),
242243
credentials: 'same-origin',
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot } from 'react-dom/client'
6+
import { afterEach, describe, expect, it, vi } from 'vitest'
7+
import { ChatFileDownload } from '@/app/(interfaces)/chat/components/message/components/file-download'
8+
import type { ChatFile } from '@/app/(interfaces)/chat/components/message/message'
9+
10+
const imageFile: ChatFile = {
11+
id: 'file-image',
12+
name: 'generated.png',
13+
key: 'execution/generated.png',
14+
url: 'https://files.example.com/generated.png',
15+
size: 3,
16+
type: 'image/png',
17+
base64: 'YWJj',
18+
}
19+
20+
const mounts: Array<() => void> = []
21+
22+
function renderFile(file: ChatFile): HTMLDivElement {
23+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
24+
const container = document.createElement('div')
25+
const root = createRoot(container)
26+
act(() => root.render(<ChatFileDownload file={file} />))
27+
mounts.push(() => act(() => root.unmount()))
28+
return container
29+
}
30+
31+
afterEach(() => {
32+
while (mounts.length) mounts.pop()?.()
33+
vi.restoreAllMocks()
34+
})
35+
36+
describe('ChatFileDownload', () => {
37+
it('previews returned image bytes inline without requiring a workspace session', () => {
38+
const container = renderFile(imageFile)
39+
const image = container.querySelector('img')
40+
expect(image?.getAttribute('src')).toBe('data:image/png;base64,YWJj')
41+
expect(image?.alt).toBe('generated.png')
42+
expect(container.querySelector('button')?.textContent).toContain('generated.png')
43+
})
44+
45+
it('uses the file URL when inline bytes are unavailable', () => {
46+
const container = renderFile({ ...imageFile, base64: undefined })
47+
expect(container.querySelector('img')?.getAttribute('src')).toBe(imageFile.url)
48+
})
49+
50+
it('uses the canonical serve route for unsafe file URLs', () => {
51+
const container = renderFile({ ...imageFile, base64: undefined, url: 'javascript:alert(1)' })
52+
expect(container.querySelector('img')?.getAttribute('src')).toBe(
53+
'/api/files/serve/execution%2Fgenerated.png?context=execution'
54+
)
55+
})
56+
57+
it('keeps a download available when an image preview fails', () => {
58+
const container = renderFile(imageFile)
59+
act(() => container.querySelector('img')!.dispatchEvent(new Event('error')))
60+
expect(container.querySelector('img')).toBeNull()
61+
expect(container.querySelector('button')?.textContent).toContain('generated.png')
62+
})
63+
64+
it('renders documents as downloads without an image preview', () => {
65+
const container = renderFile({ ...imageFile, name: 'report.pdf', type: 'application/pdf' })
66+
expect(container.querySelector('img')).toBeNull()
67+
expect(container.querySelector('button')?.textContent).toContain('report.pdf')
68+
})
69+
})

apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ function isImageFile(mimeType: string): boolean {
5151
}
5252

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

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

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

8084
const handleDownload = async () => {
8185
if (isDownloading) return
@@ -109,25 +113,36 @@ export function ChatFileDownload({ file }: ChatFileDownloadProps) {
109113
}
110114

111115
return (
112-
<Button
113-
variant='default'
114-
onClick={handleDownload}
115-
disabled={isDownloading}
116-
className='group flex h-auto w-[200px] items-center gap-2 rounded-lg px-3 py-2'
117-
>
118-
<div className='flex size-8 shrink-0 items-center justify-center'>{renderIcon()}</div>
119-
<div className='min-w-0 flex-1 text-left'>
120-
<div className='w-[100px] truncate text-xs'>{file.name}</div>
121-
<div className='text-[var(--text-muted)] text-micro'>{formatFileSize(file.size)}</div>
122-
</div>
123-
<div className='shrink-0'>
124-
{isDownloading ? (
125-
<Loader className='size-3.5' animate />
126-
) : (
127-
<Download className='size-3.5 opacity-0 transition-opacity group-hover:opacity-100' />
128-
)}
129-
</div>
130-
</Button>
116+
<div className='flex max-w-full flex-col items-start gap-2'>
117+
{isImageFile(file.type) && failedPreviewUrl !== fileUrl && (
118+
<img
119+
src={fileUrl}
120+
alt={file.name}
121+
loading='lazy'
122+
className='-outline-offset-1 max-h-[480px] max-w-full rounded-lg object-contain outline outline-1 outline-black/10 dark:outline-white/10'
123+
onError={() => setFailedPreviewUrl(fileUrl)}
124+
/>
125+
)}
126+
<Button
127+
variant='default'
128+
onClick={handleDownload}
129+
disabled={isDownloading}
130+
className='group flex h-auto w-[200px] items-center gap-2 rounded-lg px-3 py-2'
131+
>
132+
<div className='flex size-8 shrink-0 items-center justify-center'>{renderIcon()}</div>
133+
<div className='min-w-0 flex-1 text-left'>
134+
<div className='w-[100px] truncate text-xs'>{file.name}</div>
135+
<div className='text-[var(--text-muted)] text-micro'>{formatFileSize(file.size)}</div>
136+
</div>
137+
<div className='shrink-0'>
138+
{isDownloading ? (
139+
<Loader className='size-3.5' animate />
140+
) : (
141+
<Download className='size-3.5 opacity-0 transition-opacity group-hover:opacity-100' />
142+
)}
143+
</div>
144+
</Button>
145+
</div>
131146
)
132147
}
133148

apps/sim/app/(interfaces)/chat/components/message/message.test.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,41 @@ describe('ClientChatMessage thinking chrome (Step 6)', () => {
8787
}
8888
})
8989

90+
it('renders no message row or copy action for empty assistant output', () => {
91+
const { container, unmount } = renderMessage({
92+
id: 'empty-output',
93+
type: 'assistant',
94+
content: '',
95+
files: [],
96+
timestamp: new Date(),
97+
})
98+
mounts.push(unmount)
99+
expect(container.innerHTML).toBe('')
100+
})
101+
102+
it('does not show a copy action for a file-only response', () => {
103+
const { container, unmount } = renderMessage({
104+
id: 'file-output',
105+
type: 'assistant',
106+
content: '',
107+
files: [
108+
{
109+
id: 'file-1',
110+
name: 'image.png',
111+
url: '/image.png',
112+
key: 'image.png',
113+
size: 3,
114+
type: 'image/png',
115+
},
116+
],
117+
timestamp: new Date(),
118+
})
119+
mounts.push(unmount)
120+
expect(container.querySelector('[data-message-id]')).not.toBeNull()
121+
expect(container.querySelector('[data-testid="answer"]')).toBeNull()
122+
expect(container.textContent).not.toContain('Copy to clipboard')
123+
})
124+
90125
it('does not show thinking chrome when thinking is absent or empty', () => {
91126
const without = renderMessage({
92127
id: '1',

apps/sim/app/(interfaces)/chat/components/message/message.tsx

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export interface ChatFile {
3030
size: number
3131
type: string
3232
context?: string
33+
base64?: string
3334
}
3435

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

104+
interface ClientChatMessageProps {
105+
message: ChatMessage
106+
}
107+
103108
export const ClientChatMessage = memo(function ClientChatMessage({
104109
message,
105-
}: {
106-
message: ChatMessage
107-
}) {
110+
}: ClientChatMessageProps) {
108111
const [isCopied, setIsCopied] = useState(false)
109112

110113
const isJsonObject = typeof message.content === 'object' && message.content !== null
@@ -113,6 +116,12 @@ export const ClientChatMessage = memo(function ClientChatMessage({
113116
const cleanTextContent = message.content
114117
const hasThinking = typeof message.thinking === 'string' && message.thinking.length > 0
115118
const hasToolCalls = Array.isArray(message.toolCalls) && message.toolCalls.length > 0
119+
const hasContent = isJsonObject || Boolean((message.content as string).trim())
120+
const hasFiles = Boolean(message.files?.length)
121+
122+
if (message.type === 'assistant' && !hasContent && !hasFiles && !hasThinking && !hasToolCalls) {
123+
return null
124+
}
116125

117126
const content =
118127
message.type === 'user' ? (
@@ -238,15 +247,17 @@ export const ClientChatMessage = memo(function ClientChatMessage({
238247
isStreaming={message.isToolStreaming}
239248
/>
240249
)}
241-
<div className='break-words text-base'>
242-
{isJsonObject ? (
243-
<pre className='text-[var(--text-primary)]'>
244-
{JSON.stringify(cleanTextContent, null, 2)}
245-
</pre>
246-
) : (
247-
<MarkdownRenderer content={cleanTextContent as string} />
248-
)}
249-
</div>
250+
{hasContent && (
251+
<div className='break-words text-base'>
252+
{isJsonObject ? (
253+
<pre className='text-[var(--text-primary)]'>
254+
{JSON.stringify(cleanTextContent, null, 2)}
255+
</pre>
256+
) : (
257+
<MarkdownRenderer content={cleanTextContent as string} />
258+
)}
259+
</div>
260+
)}
250261
</div>
251262
{message.files && message.files.length > 0 && (
252263
<div className='flex flex-wrap gap-2'>
@@ -257,7 +268,7 @@ export const ClientChatMessage = memo(function ClientChatMessage({
257268
)}
258269
{message.type === 'assistant' && !isJsonObject && !message.isInitialMessage && (
259270
<div className='flex items-center justify-start space-x-2'>
260-
{!message.isStreaming && (
271+
{!message.isStreaming && hasContent && (
261272
<Tooltip.Root>
262273
<Tooltip.Trigger asChild>
263274
<Button

0 commit comments

Comments
 (0)