Skip to content

Commit 4f98438

Browse files
committed
improvement(ui): restore smooth streaming animation, fix follow-up auto-scroll, move CopyCodeButton to emcn
1 parent 6ce299b commit 4f98438

10 files changed

Lines changed: 174 additions & 127 deletions

File tree

apps/sim/app/chat/components/message/components/markdown-renderer.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import React, { type HTMLAttributes, memo, type ReactNode, useMemo } from 'react'
22
import { Streamdown } from 'streamdown'
33
import 'streamdown/styles.css'
4-
import { Tooltip } from '@/components/emcn'
5-
import { CopyCodeButton } from '@/components/ui/copy-code-button'
4+
import { CopyCodeButton, Tooltip } from '@/components/emcn'
65
import { extractTextContent } from '@/lib/core/utils/react-node-text'
76

87
export function LinkWithPreview({ href, children }: { href: string; children: React.ReactNode }) {

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

Lines changed: 41 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@ import 'prismjs/components/prism-bash'
88
import 'prismjs/components/prism-css'
99
import 'prismjs/components/prism-markup'
1010
import '@/components/emcn/components/code/code.css'
11-
import { Checkbox, highlight, languages } from '@/components/emcn'
12-
import { CopyCodeButton } from '@/components/ui/copy-code-button'
11+
import { Checkbox, CopyCodeButton, highlight, languages } from '@/components/emcn'
1312
import { cn } from '@/lib/core/utils/cn'
1413
import { extractTextContent } from '@/lib/core/utils/react-node-text'
1514
import {
@@ -19,6 +18,7 @@ import {
1918
SpecialTags,
2019
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
2120
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
21+
import { useStreamingReveal } from '@/hooks/use-streaming-reveal'
2222
import { useStreamingText } from '@/hooks/use-streaming-text'
2323

2424
const LANG_ALIASES: Record<string, string> = {
@@ -148,7 +148,7 @@ const MARKDOWN_COMPONENTS = {
148148
<span className='text-[var(--text-tertiary)] text-xs'>{language || 'code'}</span>
149149
<CopyCodeButton
150150
code={codeString}
151-
className='text-[var(--text-tertiary)] hover:bg-[var(--surface-5)] hover:text-[var(--text-secondary)]'
151+
className='-mr-2 text-[var(--text-tertiary)] hover:bg-[var(--surface-5)] hover:text-[var(--text-secondary)]'
152152
/>
153153
</div>
154154
<div className='code-editor-theme bg-[var(--surface-5)] dark:bg-[var(--code-bg)]'>
@@ -247,30 +247,13 @@ export function ChatContent({
247247
onWorkspaceResourceSelect,
248248
smoothStreaming = true,
249249
}: ChatContentProps) {
250-
const hydratedStreamingRef = useRef(isStreaming && content.trim().length > 0)
251-
const previousIsStreamingRef = useRef(isStreaming)
252-
253-
useEffect(() => {
254-
if (!previousIsStreamingRef.current && isStreaming && content.trim().length > 0) {
255-
hydratedStreamingRef.current = true
256-
} else if (!isStreaming) {
257-
hydratedStreamingRef.current = false
258-
}
259-
previousIsStreamingRef.current = isStreaming
260-
}, [content, isStreaming])
261-
262250
const onWorkspaceResourceSelectRef = useRef(onWorkspaceResourceSelect)
263251
onWorkspaceResourceSelectRef.current = onWorkspaceResourceSelect
264252

265253
useEffect(() => {
266254
const handler = (e: Event) => {
267255
const { type, id, title } = (e as CustomEvent).detail
268-
const RESOURCE_TYPE_MAP: Record<string, string> = {}
269-
onWorkspaceResourceSelectRef.current?.({
270-
type: RESOURCE_TYPE_MAP[type] || type,
271-
id,
272-
title: title || id,
273-
})
256+
onWorkspaceResourceSelectRef.current?.({ type, id, title: title || id })
274257
}
275258
window.addEventListener('wsres-click', handler)
276259
return () => window.removeEventListener('wsres-click', handler)
@@ -281,6 +264,11 @@ export function ChatContent({
281264
const parsed = useMemo(() => parseSpecialTags(rendered, isStreaming), [rendered, isStreaming])
282265
const hasSpecialContent = parsed.hasPendingTag || parsed.segments.some((s) => s.type !== 'text')
283266

267+
const { committed, incoming, generation } = useStreamingReveal(
268+
rendered,
269+
!hasSpecialContent && isStreaming
270+
)
271+
284272
if (hasSpecialContent) {
285273
type BlockSegment = Exclude<
286274
ContentSegment,
@@ -348,15 +336,38 @@ export function ChatContent({
348336
}
349337

350338
return (
351-
<div className={cn(PROSE_CLASSES, '[&>:first-child]:mt-0 [&>:last-child]:mb-0')}>
352-
<Streamdown
353-
mode={isStreaming ? undefined : 'static'}
354-
isAnimating={isStreaming}
355-
animated={isStreaming && !hydratedStreamingRef.current}
356-
components={MARKDOWN_COMPONENTS}
357-
>
358-
{rendered}
359-
</Streamdown>
339+
<div>
340+
{committed && (
341+
<div
342+
className={cn(
343+
PROSE_CLASSES,
344+
'[&>:first-child]:mt-0',
345+
!incoming && '[&>:last-child]:mb-0'
346+
)}
347+
>
348+
<Streamdown mode='static' components={MARKDOWN_COMPONENTS}>
349+
{committed}
350+
</Streamdown>
351+
</div>
352+
)}
353+
{incoming && (
354+
<div
355+
key={generation}
356+
className={cn(
357+
PROSE_CLASSES,
358+
'[&>:first-child]:mt-0 [&>:last-child]:mb-0',
359+
isStreaming && 'animate-stream-fade-in'
360+
)}
361+
>
362+
<Streamdown
363+
mode={isStreaming ? undefined : 'static'}
364+
isAnimating={isStreaming}
365+
components={MARKDOWN_COMPONENTS}
366+
>
367+
{incoming}
368+
</Streamdown>
369+
</div>
370+
)}
360371
</div>
361372
)
362373
}

apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ export function UserInput({
232232
const canSubmit = (value.trim().length > 0 || hasFiles) && !isSending
233233

234234
const valueRef = useRef(value)
235+
valueRef.current = value
235236
const sttPrefixRef = useRef('')
236237

237238
const handleTranscript = useCallback((text: string) => {
@@ -271,10 +272,6 @@ export function UserInput({
271272
const isSendingRef = useRef(isSending)
272273
isSendingRef.current = isSending
273274

274-
useEffect(() => {
275-
valueRef.current = value
276-
}, [value])
277-
278275
const textareaRef = mentionMenu.textareaRef
279276
const wasSendingRef = useRef(false)
280277
const atInsertPosRef = useRef<number | null>(null)
@@ -358,9 +355,7 @@ export function UserInput({
358355
}
359356
// Reset after batch so the next non-drop insert uses the cursor position
360357
atInsertPosRef.current = null
361-
} catch {
362-
// Invalid JSON — ignore
363-
}
358+
} catch {}
364359
textareaRef.current?.focus()
365360
return
366361
}
@@ -372,9 +367,7 @@ export function UserInput({
372367
const resource = JSON.parse(resourceJson) as MothershipResource
373368
handleResourceSelect(resource)
374369
atInsertPosRef.current = null
375-
} catch {
376-
// Invalid JSON — ignore
377-
}
370+
} catch {}
378371
textareaRef.current?.focus()
379372
return
380373
}

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

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
55
import { useParams, useRouter, useSearchParams } from 'next/navigation'
66
import { usePostHog } from 'posthog-js/react'
7+
import { Button } from '@/components/emcn'
78
import { PanelLeft } from '@/components/emcn/icons'
89
import { useSession } from '@/lib/auth/auth-client'
910
import {
@@ -33,6 +34,7 @@ export function Home({ chatId }: HomeProps = {}) {
3334
const { data: session } = useSession()
3435
const posthog = usePostHog()
3536
const posthogRef = useRef(posthog)
37+
posthogRef.current = posthog
3638
const [initialPrompt, setInitialPrompt] = useState('')
3739
const hasCheckedLandingStorageRef = useRef(false)
3840
const initialViewInputRef = useRef<HTMLDivElement>(null)
@@ -99,19 +101,12 @@ export function Home({ chatId }: HomeProps = {}) {
99101
return
100102
}
101103

102-
// const templateId = LandingTemplateStorage.consume()
103-
// if (templateId) {
104-
// logger.info('Retrieved landing page template, redirecting to template detail')
105-
// router.replace(`/workspace/${workspaceId}/templates/${templateId}?use=true`)
106-
// return
107-
// }
108-
109104
const prompt = LandingPromptStorage.consume()
110105
if (prompt) {
111106
logger.info('Retrieved landing page prompt, populating home input')
112107
setInitialPrompt(prompt)
113108
}
114-
}, [createWorkflowFromLandingSeed, workspaceId, router])
109+
}, [createWorkflowFromLandingSeed])
115110

116111
const wasSendingRef = useRef(false)
117112

@@ -130,10 +125,6 @@ export function Home({ chatId }: HomeProps = {}) {
130125
setIsResourceCollapsed(true)
131126
}, [clearWidth])
132127

133-
const expandResource = useCallback(() => {
134-
setIsResourceCollapsed(false)
135-
}, [])
136-
137128
const handleResourceEvent = useCallback(() => {
138129
if (isResourceCollapsedRef.current) {
139130
setIsResourceCollapsed(false)
@@ -224,10 +215,6 @@ export function Home({ chatId }: HomeProps = {}) {
224215
return () => cancelAnimationFrame(id)
225216
}, [resources])
226217

227-
useEffect(() => {
228-
posthogRef.current = posthog
229-
}, [posthog])
230-
231218
const handleStopGeneration = useCallback(() => {
232219
captureEvent(posthogRef.current, 'task_generation_aborted', {
233220
workspace_id: workspaceId,
@@ -299,9 +286,14 @@ export function Home({ chatId }: HomeProps = {}) {
299286
const handleInitialContextRemove = useCallback(
300287
(context: ChatContext) => {
301288
const resolved = resolveResourceFromContext(context)
302-
if (resolved) removeResource(resolved.type, resolved.id)
289+
if (!resolved) return
290+
removeResource(resolved.type, resolved.id)
291+
const remaining = resources.filter((r) => !(r.type === resolved.type && r.id === resolved.id))
292+
if (remaining.length === 0) {
293+
collapseResource()
294+
}
303295
},
304-
[resolveResourceFromContext, removeResource]
296+
[resolveResourceFromContext, removeResource, resources, collapseResource]
305297
)
306298

307299
const handleWorkspaceResourceSelect = useCallback(
@@ -426,14 +418,16 @@ export function Home({ chatId }: HomeProps = {}) {
426418

427419
{isResourceCollapsed && (
428420
<div className='absolute top-[8.5px] right-[16px]'>
429-
<button
421+
<Button
422+
variant='ghost'
423+
size={null}
430424
type='button'
431-
onClick={expandResource}
432-
className='flex h-[30px] w-[30px] items-center justify-center rounded-[8px] hover-hover:bg-[var(--surface-active)]'
425+
onClick={() => setIsResourceCollapsed(false)}
426+
className='h-[30px] w-[30px] rounded-[8px] hover-hover:bg-[var(--surface-active)]'
433427
aria-label='Expand resource view'
434428
>
435429
<PanelLeft className='h-[16px] w-[16px] text-[var(--text-icon)]' />
436-
</button>
430+
</Button>
437431
</div>
438432
)}
439433
</div>

apps/sim/components/ui/copy-code-button.tsx renamed to apps/sim/components/emcn/components/code/copy-code-button.tsx

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

33
import { useCallback, useEffect, useRef, useState } from 'react'
4-
import { Check, Copy } from '@/components/emcn'
4+
import { Button, Check, Copy } from '@/components/emcn'
55
import { cn } from '@/lib/core/utils/cn'
66

77
interface CopyCodeButtonProps {
@@ -19,9 +19,7 @@ export function CopyCodeButton({ code, className }: CopyCodeButtonProps) {
1919
setCopied(true)
2020
if (timerRef.current) clearTimeout(timerRef.current)
2121
timerRef.current = setTimeout(() => setCopied(false), 2000)
22-
} catch {
23-
// Clipboard write can fail when document lacks focus or permission is denied
24-
}
22+
} catch {}
2523
}, [code])
2624

2725
useEffect(
@@ -32,15 +30,13 @@ export function CopyCodeButton({ code, className }: CopyCodeButtonProps) {
3230
)
3331

3432
return (
35-
<button
33+
<Button
3634
type='button'
35+
variant='ghost'
3736
onClick={handleCopy}
38-
className={cn(
39-
'flex items-center gap-1 rounded px-1.5 py-0.5 text-xs transition-colors',
40-
className
41-
)}
37+
className={cn('flex items-center gap-1 rounded px-1.5 py-0.5 text-xs', className)}
4238
>
4339
{copied ? <Check className='size-3.5' /> : <Copy className='size-3.5' />}
44-
</button>
40+
</Button>
4541
)
4642
}

apps/sim/components/emcn/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export {
3232
highlight,
3333
languages,
3434
} from './code/code'
35+
export { CopyCodeButton } from './code/copy-code-button'
3536
export {
3637
Combobox,
3738
type ComboboxOption,

apps/sim/hooks/use-auto-scroll.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,12 @@ export function useAutoScroll(
117117
el.removeEventListener('scroll', onScroll)
118118
observer.disconnect()
119119
cancelAnimationFrame(rafIdRef.current)
120-
if (stickyRef.current) scrollToBottom()
120+
if (stickyRef.current) {
121+
scrollToBottom()
122+
const settled = new MutationObserver(() => scrollToBottom())
123+
settled.observe(el, { childList: true, subtree: true })
124+
setTimeout(() => settled.disconnect(), 300)
125+
}
121126
}
122127
}, [isStreaming, scrollToBottom])
123128

0 commit comments

Comments
 (0)