From 62d922ffa95b4551af43dc3fa0d32297bd9039b9 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 14 Sep 2026 15:35:03 -0700 Subject: [PATCH 1/2] fix(chat): render deployed file outputs inline --- .../(interfaces)/chat/[identifier]/chat.tsx | 3 +- .../message/components/file-download.test.tsx | 69 ++++++++ .../message/components/file-download.tsx | 53 ++++--- .../chat/components/message/message.test.tsx | 35 +++++ .../chat/components/message/message.tsx | 37 +++-- .../chat/hooks/use-chat-streaming.test.tsx | 108 ++++++++++++- .../chat/hooks/use-chat-streaming.ts | 147 +++++++----------- .../streaming/agent-stream-protocol.test.ts | 30 ++++ .../streaming/agent-stream-protocol.ts | 31 +++- .../lib/workflows/streaming/streaming.test.ts | 132 ++++++++++++++++ apps/sim/lib/workflows/streaming/streaming.ts | 31 +++- 11 files changed, 549 insertions(+), 127 deletions(-) create mode 100644 apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx diff --git a/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx b/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx index f066279140d..beadfdc58e9 100644 --- a/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx +++ b/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx @@ -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 { @@ -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', diff --git a/apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx new file mode 100644 index 00000000000..b386babcad2 --- /dev/null +++ b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx @@ -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()) + 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') + }) +}) diff --git a/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx index a043bd433df..f9005b8b7e5 100644 --- a/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx @@ -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'}` } @@ -76,6 +78,8 @@ async function triggerDownload(url: string, filename: string): Promise { export function ChatFileDownload({ file }: ChatFileDownloadProps) { const [isDownloading, setIsDownloading] = useState(false) + const [failedPreviewUrl, setFailedPreviewUrl] = useState(null) + const fileUrl = getFileUrl(file) const handleDownload = async () => { if (isDownloading) return @@ -109,25 +113,36 @@ export function ChatFileDownload({ file }: ChatFileDownloadProps) { } return ( - +
+ {isImageFile(file.type) && failedPreviewUrl !== fileUrl && ( + {file.name} setFailedPreviewUrl(fileUrl)} + /> + )} + +
) } diff --git a/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx b/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx index 43daf5cd018..1726c423397 100644 --- a/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx @@ -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', diff --git a/apps/sim/app/(interfaces)/chat/components/message/message.tsx b/apps/sim/app/(interfaces)/chat/components/message/message.tsx index ea2c6d4ab1a..8b4cd29647f 100644 --- a/apps/sim/app/(interfaces)/chat/components/message/message.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message/message.tsx @@ -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. */ @@ -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 @@ -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' ? ( @@ -238,15 +247,17 @@ export const ClientChatMessage = memo(function ClientChatMessage({ isStreaming={message.isToolStreaming} /> )} -
- {isJsonObject ? ( -
-                    {JSON.stringify(cleanTextContent, null, 2)}
-                  
- ) : ( - - )} -
+ {hasContent && ( +
+ {isJsonObject ? ( +
+                      {JSON.stringify(cleanTextContent, null, 2)}
+                    
+ ) : ( + + )} +
+ )} {message.files && message.files.length > 0 && (
@@ -257,7 +268,7 @@ export const ClientChatMessage = memo(function ClientChatMessage({ )} {message.type === 'assistant' && !isJsonObject && !message.isInitialMessage && (
- {!message.isStreaming && ( + {!message.isStreaming && hasContent && (