Skip to content

Commit 92fd749

Browse files
committed
Streaming fixes
1 parent bebe120 commit 92fd749

File tree

5 files changed

+264
-121
lines changed

5 files changed

+264
-121
lines changed

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
useWorkspaceFileContent,
1212
} from '@/hooks/queries/workspace-files'
1313
import { useAutosave } from '@/hooks/use-autosave'
14+
import { useStreamingText } from '@/hooks/use-streaming-text'
1415
import { PreviewPanel, resolvePreviewType } from './preview-panel'
1516

1617
const logger = createLogger('FileViewer')
@@ -261,6 +262,9 @@ function TextEditor({
261262
}
262263
}, [isResizing])
263264

265+
const isStreaming = streamingContent !== undefined
266+
const revealedContent = useStreamingText(content, isStreaming)
267+
264268
if (streamingContent === undefined) {
265269
if (isLoading) {
266270
return (
@@ -290,7 +294,7 @@ function TextEditor({
290294
{showEditor && (
291295
<textarea
292296
ref={textareaRef}
293-
value={content}
297+
value={isStreaming ? revealedContent : content}
294298
onChange={(e) => handleContentChange(e.target.value)}
295299
readOnly={!canEdit}
296300
spellCheck={false}
@@ -322,7 +326,7 @@ function TextEditor({
322326
<div
323327
className={cn('min-w-0 flex-1 overflow-hidden', isResizing && 'pointer-events-none')}
324328
>
325-
<PreviewPanel content={content} mimeType={file.type} filename={file.name} />
329+
<PreviewPanel content={revealedContent} mimeType={file.type} filename={file.name} isStreaming={isStreaming} />
326330
</div>
327331
</>
328332
)}

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx

Lines changed: 146 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { memo, useMemo } from 'react'
44
import ReactMarkdown from 'react-markdown'
55
import remarkBreaks from 'remark-breaks'
66
import remarkGfm from 'remark-gfm'
7+
import { cn } from '@/lib/core/utils/cn'
78
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
9+
import { useAutoScroll } from '@/app/workspace/[workspaceId]/home/hooks/use-auto-scroll'
10+
import { useStreamingReveal } from '@/app/workspace/[workspaceId]/home/hooks/use-streaming-reveal'
811

912
type PreviewType = 'markdown' | 'html' | 'csv' | 'svg' | null
1013

@@ -36,134 +39,163 @@ interface PreviewPanelProps {
3639
content: string
3740
mimeType: string | null
3841
filename: string
42+
isStreaming?: boolean
3943
}
4044

41-
export function PreviewPanel({ content, mimeType, filename }: PreviewPanelProps) {
45+
export function PreviewPanel({ content, mimeType, filename, isStreaming }: PreviewPanelProps) {
4246
const previewType = resolvePreviewType(mimeType, filename)
4347

44-
if (previewType === 'markdown') return <MarkdownPreview content={content} />
48+
if (previewType === 'markdown') return <MarkdownPreview content={content} isStreaming={isStreaming} />
4549
if (previewType === 'html') return <HtmlPreview content={content} />
4650
if (previewType === 'csv') return <CsvPreview content={content} />
4751
if (previewType === 'svg') return <SvgPreview content={content} />
4852

4953
return null
5054
}
5155

52-
const MarkdownPreview = memo(function MarkdownPreview({ content }: { content: string }) {
53-
return (
54-
<div className='h-full overflow-auto p-[24px]'>
55-
<ReactMarkdown
56-
remarkPlugins={[remarkGfm, remarkBreaks]}
57-
components={{
58-
p: ({ children }: any) => (
59-
<p className='mb-3 break-words text-[14px] text-[var(--text-primary)] leading-[1.6] last:mb-0'>
60-
{children}
61-
</p>
62-
),
63-
h1: ({ children }: any) => (
64-
<h1 className='mt-6 mb-4 break-words border-[var(--border)] border-b pb-2 font-semibold text-[24px] text-[var(--text-primary)] first:mt-0'>
65-
{children}
66-
</h1>
67-
),
68-
h2: ({ children }: any) => (
69-
<h2 className='mt-5 mb-3 break-words border-[var(--border)] border-b pb-1.5 font-semibold text-[20px] text-[var(--text-primary)] first:mt-0'>
70-
{children}
71-
</h2>
72-
),
73-
h3: ({ children }: any) => (
74-
<h3 className='mt-4 mb-2 break-words font-semibold text-[16px] text-[var(--text-primary)] first:mt-0'>
75-
{children}
76-
</h3>
77-
),
78-
h4: ({ children }: any) => (
79-
<h4 className='mt-3 mb-2 break-words font-semibold text-[14px] text-[var(--text-primary)] first:mt-0'>
80-
{children}
81-
</h4>
82-
),
83-
ul: ({ children }: any) => (
84-
<ul className='mt-1 mb-3 list-disc space-y-1 break-words pl-6 text-[14px] text-[var(--text-primary)]'>
85-
{children}
86-
</ul>
87-
),
88-
ol: ({ children }: any) => (
89-
<ol className='mt-1 mb-3 list-decimal space-y-1 break-words pl-6 text-[14px] text-[var(--text-primary)]'>
90-
{children}
91-
</ol>
92-
),
93-
li: ({ children }: any) => <li className='break-words leading-[1.6]'>{children}</li>,
94-
code: ({ inline, className, children, ...props }: any) => {
95-
const isInline = inline || !className?.includes('language-')
56+
const REMARK_PLUGINS = [remarkGfm, remarkBreaks]
9657

97-
if (isInline) {
98-
return (
99-
<code
100-
{...props}
101-
className='whitespace-normal rounded bg-[var(--surface-5)] px-1.5 py-0.5 font-mono text-[#F59E0B] text-[13px]'
102-
>
103-
{children}
104-
</code>
105-
)
106-
}
58+
const PREVIEW_MARKDOWN_COMPONENTS = {
59+
p: ({ children }: any) => (
60+
<p className='mb-3 break-words text-[14px] text-[var(--text-primary)] leading-[1.6] last:mb-0'>
61+
{children}
62+
</p>
63+
),
64+
h1: ({ children }: any) => (
65+
<h1 className='mt-6 mb-4 break-words border-[var(--border)] border-b pb-2 font-semibold text-[24px] text-[var(--text-primary)] first:mt-0'>
66+
{children}
67+
</h1>
68+
),
69+
h2: ({ children }: any) => (
70+
<h2 className='mt-5 mb-3 break-words border-[var(--border)] border-b pb-1.5 font-semibold text-[20px] text-[var(--text-primary)] first:mt-0'>
71+
{children}
72+
</h2>
73+
),
74+
h3: ({ children }: any) => (
75+
<h3 className='mt-4 mb-2 break-words font-semibold text-[16px] text-[var(--text-primary)] first:mt-0'>
76+
{children}
77+
</h3>
78+
),
79+
h4: ({ children }: any) => (
80+
<h4 className='mt-3 mb-2 break-words font-semibold text-[14px] text-[var(--text-primary)] first:mt-0'>
81+
{children}
82+
</h4>
83+
),
84+
ul: ({ children }: any) => (
85+
<ul className='mt-1 mb-3 list-disc space-y-1 break-words pl-6 text-[14px] text-[var(--text-primary)]'>
86+
{children}
87+
</ul>
88+
),
89+
ol: ({ children }: any) => (
90+
<ol className='mt-1 mb-3 list-decimal space-y-1 break-words pl-6 text-[14px] text-[var(--text-primary)]'>
91+
{children}
92+
</ol>
93+
),
94+
li: ({ children }: any) => <li className='break-words leading-[1.6]'>{children}</li>,
95+
code: ({ inline, className, children, ...props }: any) => {
96+
const isInline = inline || !className?.includes('language-')
97+
98+
if (isInline) {
99+
return (
100+
<code
101+
{...props}
102+
className='whitespace-normal rounded bg-[var(--surface-5)] px-1.5 py-0.5 font-mono text-[#F59E0B] text-[13px]'
103+
>
104+
{children}
105+
</code>
106+
)
107+
}
107108

108-
return (
109-
<code
110-
{...props}
111-
className='my-3 block whitespace-pre-wrap break-words rounded-md bg-[var(--surface-5)] p-4 font-mono text-[13px] text-[var(--text-primary)]'
112-
>
113-
{children}
114-
</code>
115-
)
116-
},
117-
pre: ({ children }: any) => <>{children}</>,
118-
a: ({ href, children }: any) => (
119-
<a
120-
href={href}
121-
target='_blank'
122-
rel='noopener noreferrer'
123-
className='break-all text-[var(--brand-secondary)] underline-offset-2 hover:underline'
124-
>
125-
{children}
126-
</a>
127-
),
128-
strong: ({ children }: any) => (
129-
<strong className='break-words font-semibold text-[var(--text-primary)]'>
130-
{children}
131-
</strong>
132-
),
133-
em: ({ children }: any) => (
134-
<em className='break-words text-[var(--text-tertiary)]'>{children}</em>
135-
),
136-
blockquote: ({ children }: any) => (
137-
<blockquote className='my-4 break-words border-[var(--border-1)] border-l-4 py-1 pl-4 text-[var(--text-tertiary)] italic'>
138-
{children}
139-
</blockquote>
140-
),
141-
hr: () => <hr className='my-6 border-[var(--border)]' />,
142-
img: ({ src, alt }: any) => (
143-
<img src={src} alt={alt ?? ''} className='my-3 max-w-full rounded-md' loading='lazy' />
144-
),
145-
table: ({ children }: any) => (
146-
<div className='my-4 max-w-full overflow-x-auto rounded-md border border-[var(--border)]'>
147-
<table className='w-full border-collapse text-[13px]'>{children}</table>
148-
</div>
149-
),
150-
thead: ({ children }: any) => <thead className='bg-[var(--surface-2)]'>{children}</thead>,
151-
tbody: ({ children }: any) => <tbody>{children}</tbody>,
152-
tr: ({ children }: any) => (
153-
<tr className='border-[var(--border)] border-b last:border-b-0'>{children}</tr>
154-
),
155-
th: ({ children }: any) => (
156-
<th className='px-3 py-2 text-left font-semibold text-[12px] text-[var(--text-primary)]'>
157-
{children}
158-
</th>
159-
),
160-
td: ({ children }: any) => (
161-
<td className='px-3 py-2 text-[var(--text-secondary)]'>{children}</td>
162-
),
163-
}}
109+
return (
110+
<code
111+
{...props}
112+
className='my-3 block whitespace-pre-wrap break-words rounded-md bg-[var(--surface-5)] p-4 font-mono text-[13px] text-[var(--text-primary)]'
164113
>
165-
{content}
166-
</ReactMarkdown>
114+
{children}
115+
</code>
116+
)
117+
},
118+
pre: ({ children }: any) => <>{children}</>,
119+
a: ({ href, children }: any) => (
120+
<a
121+
href={href}
122+
target='_blank'
123+
rel='noopener noreferrer'
124+
className='break-all text-[var(--brand-secondary)] underline-offset-2 hover:underline'
125+
>
126+
{children}
127+
</a>
128+
),
129+
strong: ({ children }: any) => (
130+
<strong className='break-words font-semibold text-[var(--text-primary)]'>
131+
{children}
132+
</strong>
133+
),
134+
em: ({ children }: any) => (
135+
<em className='break-words text-[var(--text-tertiary)]'>{children}</em>
136+
),
137+
blockquote: ({ children }: any) => (
138+
<blockquote className='my-4 break-words border-[var(--border-1)] border-l-4 py-1 pl-4 text-[var(--text-tertiary)] italic'>
139+
{children}
140+
</blockquote>
141+
),
142+
hr: () => <hr className='my-6 border-[var(--border)]' />,
143+
img: ({ src, alt }: any) => (
144+
<img src={src} alt={alt ?? ''} className='my-3 max-w-full rounded-md' loading='lazy' />
145+
),
146+
table: ({ children }: any) => (
147+
<div className='my-4 max-w-full overflow-x-auto rounded-md border border-[var(--border)]'>
148+
<table className='w-full border-collapse text-[13px]'>{children}</table>
149+
</div>
150+
),
151+
thead: ({ children }: any) => <thead className='bg-[var(--surface-2)]'>{children}</thead>,
152+
tbody: ({ children }: any) => <tbody>{children}</tbody>,
153+
tr: ({ children }: any) => (
154+
<tr className='border-[var(--border)] border-b last:border-b-0'>{children}</tr>
155+
),
156+
th: ({ children }: any) => (
157+
<th className='px-3 py-2 text-left font-semibold text-[12px] text-[var(--text-primary)]'>
158+
{children}
159+
</th>
160+
),
161+
td: ({ children }: any) => (
162+
<td className='px-3 py-2 text-[var(--text-secondary)]'>{children}</td>
163+
),
164+
}
165+
166+
const MarkdownPreview = memo(function MarkdownPreview({
167+
content,
168+
isStreaming = false,
169+
}: {
170+
content: string
171+
isStreaming?: boolean
172+
}) {
173+
const { ref: scrollRef } = useAutoScroll(isStreaming)
174+
const { committed, incoming, generation } = useStreamingReveal(content, isStreaming)
175+
176+
const committedMarkdown = useMemo(
177+
() =>
178+
committed ? (
179+
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={PREVIEW_MARKDOWN_COMPONENTS}>
180+
{committed}
181+
</ReactMarkdown>
182+
) : null,
183+
[committed]
184+
)
185+
186+
return (
187+
<div ref={scrollRef} className='h-full overflow-auto p-[24px]'>
188+
{committedMarkdown}
189+
{incoming && (
190+
<div
191+
key={generation}
192+
className={cn(isStreaming && 'animate-stream-fade-in', '[&>:first-child]:mt-0')}
193+
>
194+
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={PREVIEW_MARKDOWN_COMPONENTS}>
195+
{incoming}
196+
</ReactMarkdown>
197+
</div>
198+
)}
167199
</div>
168200
)
169201
})

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
SpecialTags,
1717
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
1818
import { useStreamingReveal } from '@/app/workspace/[workspaceId]/home/hooks/use-streaming-reveal'
19-
import { useThrottledValue } from '@/hooks/use-throttled-value'
19+
import { useStreamingText } from '@/hooks/use-streaming-text'
2020

2121
const REMARK_PLUGINS = [remarkGfm]
2222

@@ -187,11 +187,8 @@ interface ChatContentProps {
187187
onOptionSelect?: (id: string) => void
188188
}
189189

190-
const STREAMING_THROTTLE_MS = 50
191-
192190
export function ChatContent({ content, isStreaming = false, onOptionSelect }: ChatContentProps) {
193-
const throttled = useThrottledValue(content, isStreaming ? STREAMING_THROTTLE_MS : undefined)
194-
const rendered = isStreaming ? throttled : content
191+
const rendered = useStreamingText(content, isStreaming)
195192

196193
const parsed = useMemo(() => parseSpecialTags(rendered, isStreaming), [rendered, isStreaming])
197194
const hasSpecialContent = parsed.hasPendingTag || parsed.segments.some((s) => s.type !== 'text')

apps/sim/app/workspace/[workspaceId]/home/hooks/use-streaming-reveal.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ export function useStreamingReveal(content: string, isStreaming: boolean): Strea
7474
}
7575
}, [content, isStreaming])
7676

77+
if (!isStreaming) {
78+
return { committed: content, incoming: '', generation }
79+
}
80+
7781
if (committedEnd > 0 && committedEnd < content.length) {
7882
return {
7983
committed: content.slice(0, committedEnd),
@@ -82,5 +86,12 @@ export function useStreamingReveal(content: string, isStreaming: boolean): Strea
8286
}
8387
}
8488

89+
// No paragraph split yet: keep the growing markdown in `incoming` only so ReactMarkdown
90+
// re-parses one tail block (same as the paragraph-tail path). Putting everything in
91+
// `committed` would re-render the full document every tick and makes tables jump.
92+
if (committedEnd === 0 && content.length > 0) {
93+
return { committed: '', incoming: content, generation }
94+
}
95+
8596
return { committed: content, incoming: '', generation }
8697
}

0 commit comments

Comments
 (0)