Skip to content

Commit f780aaf

Browse files
committed
improvement(ux): streaming
1 parent a4e5e30 commit f780aaf

File tree

9 files changed

+227
-27
lines changed

9 files changed

+227
-27
lines changed

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

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useState } from 'react'
3+
import { useEffect, useRef, useState } from 'react'
44
import { ChevronDown } from '@/components/emcn'
55
import { cn } from '@/lib/core/utils/cn'
66
import type { ToolCallStatus } from '../../../../types'
@@ -18,12 +18,39 @@ interface AgentGroupProps {
1818
agentName: string
1919
agentLabel: string
2020
tools: ToolCallData[]
21+
autoCollapse?: boolean
2122
}
2223

23-
export function AgentGroup({ agentName, agentLabel, tools }: AgentGroupProps) {
24+
const FADE_MS = 300
25+
26+
export function AgentGroup({
27+
agentName,
28+
agentLabel,
29+
tools,
30+
autoCollapse = false,
31+
}: AgentGroupProps) {
2432
const AgentIcon = getAgentIcon(agentName)
25-
const [expanded, setExpanded] = useState(true)
2633
const hasTools = tools.length > 0
34+
const allDone = hasTools && tools.every((t) => t.status === 'success' || t.status === 'error')
35+
36+
const [expanded, setExpanded] = useState(!allDone)
37+
const [mounted, setMounted] = useState(!allDone)
38+
const didAutoCollapseRef = useRef(allDone)
39+
40+
useEffect(() => {
41+
if (!autoCollapse || didAutoCollapseRef.current) return
42+
didAutoCollapseRef.current = true
43+
setExpanded(false)
44+
}, [autoCollapse])
45+
46+
useEffect(() => {
47+
if (expanded) {
48+
setMounted(true)
49+
return
50+
}
51+
const timer = setTimeout(() => setMounted(false), FADE_MS)
52+
return () => clearTimeout(timer)
53+
}, [expanded])
2754

2855
return (
2956
<div className='flex flex-col gap-1.5'>
@@ -47,8 +74,13 @@ export function AgentGroup({ agentName, agentLabel, tools }: AgentGroupProps) {
4774
/>
4875
)}
4976
</button>
50-
{hasTools && expanded && (
51-
<div className='flex flex-col gap-1.5'>
77+
{hasTools && mounted && (
78+
<div
79+
className={cn(
80+
'flex flex-col gap-1.5 transition-opacity duration-300 ease-out',
81+
expanded ? 'opacity-100' : 'opacity-0'
82+
)}
83+
>
5284
{tools.map((tool) => (
5385
<ToolCallItem
5486
key={tool.id}

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

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { Children, type ComponentPropsWithoutRef, isValidElement } from 'react'
3+
import { Children, type ComponentPropsWithoutRef, isValidElement, useMemo } from 'react'
44
import ReactMarkdown from 'react-markdown'
55
import remarkGfm from 'remark-gfm'
66
import 'prismjs/components/prism-typescript'
@@ -10,6 +10,7 @@ import 'prismjs/components/prism-markup'
1010
import '@/components/emcn/components/code/code.css'
1111
import { Checkbox, highlight, languages } from '@/components/emcn'
1212
import { cn } from '@/lib/core/utils/cn'
13+
import { useStreamingReveal } from '@/app/workspace/[workspaceId]/home/hooks/use-streaming-reveal'
1314
import { useThrottledValue } from '@/hooks/use-throttled-value'
1415

1516
const REMARK_PLUGINS = [remarkGfm]
@@ -177,15 +178,39 @@ const MARKDOWN_COMPONENTS: React.ComponentProps<typeof ReactMarkdown>['component
177178

178179
interface ChatContentProps {
179180
content: string
181+
isStreaming?: boolean
180182
}
181183

182-
export function ChatContent({ content }: ChatContentProps) {
183-
const throttled = useThrottledValue(content)
184+
const STREAMING_THROTTLE_MS = 50
185+
186+
export function ChatContent({ content, isStreaming = false }: ChatContentProps) {
187+
const throttled = useThrottledValue(content, isStreaming ? STREAMING_THROTTLE_MS : undefined)
188+
const rendered = isStreaming ? throttled : content
189+
const { committed, incoming, generation } = useStreamingReveal(rendered, isStreaming)
190+
191+
const committedMarkdown = useMemo(
192+
() =>
193+
committed ? (
194+
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
195+
{committed}
196+
</ReactMarkdown>
197+
) : null,
198+
[committed]
199+
)
200+
184201
return (
185202
<div className={PROSE_CLASSES}>
186-
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
187-
{throttled}
188-
</ReactMarkdown>
203+
{committedMarkdown}
204+
{incoming && (
205+
<div
206+
key={generation}
207+
className={cn(isStreaming && 'animate-stream-fade-in', '[&>:first-child]:mt-0')}
208+
>
209+
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
210+
{incoming}
211+
</ReactMarkdown>
212+
</div>
213+
)}
189214
</div>
190215
)
191216
}

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

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,12 @@ interface MessageContentProps {
179179
onOptionSelect?: (id: string) => void
180180
}
181181

182-
export function MessageContent({ blocks, fallbackContent, onOptionSelect }: MessageContentProps) {
182+
export function MessageContent({
183+
blocks,
184+
fallbackContent,
185+
isStreaming = false,
186+
onOptionSelect,
187+
}: MessageContentProps) {
183188
const parsed = blocks.length > 0 ? parseBlocks(blocks) : []
184189

185190
const segments: MessageSegment[] =
@@ -196,18 +201,34 @@ export function MessageContent({ blocks, fallbackContent, onOptionSelect }: Mess
196201
{segments.map((segment, i) => {
197202
switch (segment.type) {
198203
case 'text':
199-
return <ChatContent key={`text-${i}`} content={segment.content} />
200-
case 'agent_group':
201204
return (
202-
<AgentGroup
203-
key={segment.id}
204-
agentName={segment.agentName}
205-
agentLabel={segment.agentLabel}
206-
tools={segment.tools}
207-
/>
205+
<ChatContent key={`text-${i}`} content={segment.content} isStreaming={isStreaming} />
208206
)
207+
case 'agent_group': {
208+
const allToolsDone =
209+
segment.tools.length > 0 &&
210+
segment.tools.every((t) => t.status === 'success' || t.status === 'error')
211+
const hasFollowingText = segments.slice(i + 1).some((s) => s.type === 'text')
212+
return (
213+
<div key={segment.id} className={isStreaming ? 'animate-stream-fade-in' : undefined}>
214+
<AgentGroup
215+
agentName={segment.agentName}
216+
agentLabel={segment.agentLabel}
217+
tools={segment.tools}
218+
autoCollapse={allToolsDone && hasFollowingText}
219+
/>
220+
</div>
221+
)
222+
}
209223
case 'options':
210-
return <Options key={`options-${i}`} items={segment.items} onSelect={onOptionSelect} />
224+
return (
225+
<div
226+
key={`options-${i}`}
227+
className={isStreaming ? 'animate-stream-fade-in' : undefined}
228+
>
229+
<Options items={segment.items} onSelect={onOptionSelect} />
230+
</div>
231+
)
211232
}
212233
})}
213234
</div>

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use client'
22

3+
import { cn } from '@/lib/core/utils/cn'
34
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
45
import { ResourceContent, ResourceTabs } from './components'
56

@@ -8,6 +9,7 @@ interface MothershipViewProps {
89
resources: MothershipResource[]
910
activeResourceId: string | null
1011
onSelectResource: (id: string) => void
12+
className?: string
1113
}
1214

1315
/**
@@ -20,11 +22,17 @@ export function MothershipView({
2022
resources,
2123
activeResourceId,
2224
onSelectResource,
25+
className,
2326
}: MothershipViewProps) {
2427
const active = resources.find((r) => r.id === activeResourceId) ?? resources[0] ?? null
2528

2629
return (
27-
<div className='flex h-full w-[50%] min-w-[400px] flex-col border-[var(--border)] border-l'>
30+
<div
31+
className={cn(
32+
'flex h-full w-[50%] min-w-[400px] flex-col border-[var(--border)] border-l',
33+
className
34+
)}
35+
>
2836
<ResourceTabs
2937
resources={resources}
3038
activeId={active?.id ?? null}

apps/sim/app/workspace/[workspaceId]/home/home.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
LandingWorkflowSeedStorage,
1313
} from '@/lib/core/utils/browser-storage'
1414
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
15+
import { useSidebarStore } from '@/stores/sidebar/store'
1516
import { MessageContent, MothershipView, UserInput } from './components'
1617
import type { FileAttachmentForApi } from './components/user-input/user-input'
1718
import { useChat } from './hooks'
@@ -111,6 +112,17 @@ export function Home({ chatId }: HomeProps = {}) {
111112
setActiveResourceId,
112113
} = useChat(workspaceId, chatId)
113114

115+
const prevResourceCountRef = useRef(resources.length)
116+
const animateResourcePanel =
117+
prevResourceCountRef.current === 0 && resources.length > 0 && isSending
118+
useEffect(() => {
119+
if (animateResourcePanel) {
120+
const { isCollapsed, toggleCollapsed } = useSidebarStore.getState()
121+
if (!isCollapsed) toggleCollapsed()
122+
}
123+
prevResourceCountRef.current = resources.length
124+
})
125+
114126
const handleSubmit = useCallback(
115127
(fileAttachments?: FileAttachmentForApi[]) => {
116128
const trimmed = inputValue.trim()
@@ -209,6 +221,7 @@ export function Home({ chatId }: HomeProps = {}) {
209221
resources={resources}
210222
activeResourceId={activeResourceId}
211223
onSelectResource={setActiveResourceId}
224+
className={animateResourcePanel ? 'animate-slide-in-right' : undefined}
212225
/>
213226
)}
214227
</div>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { useAnimatedPlaceholder } from './use-animated-placeholder'
22
export type { UseChatReturn } from './use-chat'
33
export { useChat } from './use-chat'
4+
export { useStreamingReveal } from './use-streaming-reveal'
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
'use client'
2+
3+
import { useEffect, useRef, useState } from 'react'
4+
5+
/**
6+
* Finds the last paragraph break (`\n\n`) that is not inside a fenced code
7+
* block. Returns the index immediately after the break — the start of the
8+
* next paragraph — so the caller can slice cleanly.
9+
*/
10+
function findSafeSplitPoint(content: string): number {
11+
let inCodeBlock = false
12+
let lastSafeBreak = 0
13+
let i = 0
14+
15+
while (i < content.length) {
16+
if (content[i] === '`' && content[i + 1] === '`' && content[i + 2] === '`') {
17+
inCodeBlock = !inCodeBlock
18+
i += 3
19+
continue
20+
}
21+
22+
if (!inCodeBlock && content[i] === '\n' && content[i + 1] === '\n') {
23+
lastSafeBreak = i + 2
24+
i += 2
25+
continue
26+
}
27+
28+
i++
29+
}
30+
31+
return lastSafeBreak
32+
}
33+
34+
interface StreamingRevealResult {
35+
/** Stable head — paragraphs that have fully arrived. Ideal for memoisation. */
36+
committed: string
37+
/** Active tail — the paragraph currently being streamed, animated on mount. */
38+
incoming: string
39+
/** Increments each time committed advances; use as React key to retrigger the fade animation. */
40+
generation: number
41+
}
42+
43+
/**
44+
* Splits streaming markdown into a stable *committed* head and an animated
45+
* *incoming* tail. The split always occurs at a paragraph boundary (`\n\n`)
46+
* that is outside fenced code blocks, so both halves are valid markdown.
47+
*
48+
* Committed content changes infrequently (only at paragraph breaks), making
49+
* it safe to wrap in `useMemo`. The incoming tail is small and re-renders at
50+
* the caller's throttle rate.
51+
*
52+
* The split is preserved after streaming ends to prevent layout shifts caused
53+
* by DOM restructuring. It only resets when content clears (new message).
54+
*/
55+
export function useStreamingReveal(content: string, isStreaming: boolean): StreamingRevealResult {
56+
const [committedEnd, setCommittedEnd] = useState(0)
57+
const [generation, setGeneration] = useState(0)
58+
const prevSplitRef = useRef(0)
59+
60+
useEffect(() => {
61+
if (content.length === 0) {
62+
prevSplitRef.current = 0
63+
setCommittedEnd(0)
64+
return
65+
}
66+
67+
if (!isStreaming) return
68+
69+
const splitPoint = findSafeSplitPoint(content)
70+
if (splitPoint > prevSplitRef.current) {
71+
prevSplitRef.current = splitPoint
72+
setCommittedEnd(splitPoint)
73+
setGeneration((g) => g + 1)
74+
}
75+
}, [content, isStreaming])
76+
77+
if (committedEnd > 0 && committedEnd < content.length) {
78+
return {
79+
committed: content.slice(0, committedEnd),
80+
incoming: content.slice(committedEnd),
81+
generation,
82+
}
83+
}
84+
85+
return { committed: content, incoming: '', generation }
86+
}

apps/sim/hooks/use-throttled-value.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,28 @@
22

33
import { useEffect, useRef, useState } from 'react'
44

5-
const TEXT_RENDER_THROTTLE_MS = 100
5+
const DEFAULT_THROTTLE_MS = 100
66

77
/**
88
* Trailing-edge throttle for rendered string values.
99
*
1010
* The underlying data accumulates instantly via the caller's state, but this
11-
* hook gates DOM re-renders to at most every {@link TEXT_RENDER_THROTTLE_MS}ms.
11+
* hook gates DOM re-renders to at most every `intervalMs` milliseconds.
1212
* When streaming stops (i.e. the value settles), the final value is flushed
1313
* immediately so no trailing content is lost.
14+
*
15+
* @param value The raw string that may update very frequently.
16+
* @param intervalMs Throttle window in ms. Lower values = smoother updates
17+
* at the cost of more renders. Defaults to 100ms.
1418
*/
15-
export function useThrottledValue(value: string): string {
19+
export function useThrottledValue(value: string, intervalMs = DEFAULT_THROTTLE_MS): string {
1620
const [displayed, setDisplayed] = useState(value)
1721
const lastFlushRef = useRef(0)
1822
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
1923

2024
useEffect(() => {
2125
const now = Date.now()
22-
const remaining = TEXT_RENDER_THROTTLE_MS - (now - lastFlushRef.current)
26+
const remaining = intervalMs - (now - lastFlushRef.current)
2327

2428
if (remaining <= 0) {
2529
if (timerRef.current !== undefined) {
@@ -44,7 +48,7 @@ export function useThrottledValue(value: string): string {
4448
timerRef.current = undefined
4549
}
4650
}
47-
}, [value])
51+
}, [value, intervalMs])
4852

4953
return displayed
5054
}

0 commit comments

Comments
 (0)