Skip to content

fix(stream): add shared StreamingText component for smooth streaming animation#3423

Open
waleedlatif1 wants to merge 3 commits intostagingfrom
fix/stream
Open

fix(stream): add shared StreamingText component for smooth streaming animation#3423
waleedlatif1 wants to merge 3 commits intostagingfrom
fix/stream

Conversation

@waleedlatif1
Copy link
Collaborator

Summary

  • Add shared StreamingText component with rAF-based animation (1800 chars/sec vs old 333 chars/sec)
  • Add streaming animation to floating chat and workspace chat (previously had none)
  • Deduplicate copilot's SmoothStreamingText to delegate to shared component
  • Clean up import paths — StreamingIndicator now comes from @/components/ui

Type of Change

  • Bug fix

Testing

Tested manually

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

@cursor
Copy link

cursor bot commented Mar 5, 2026

PR Summary

Medium Risk
Touches chat streaming/rendering paths and increases live state updates during SSE streaming, which could impact performance or introduce UI regressions. No auth/data persistence changes.

Overview
Adds a shared StreamingText component (with requestAnimationFrame-batched character progression) and a shared StreamingIndicator, exported from @/components/ui.

Updates floating chat, workspace chat, and Copilot message rendering to use StreamingText with per-surface renderers (Markdown/word-wrap), and simplifies Copilot’s SmoothStreamingText to delegate to the shared implementation.

Adjusts useChatStreaming to push content updates into the message state on each received chunk so the UI can animate incremental streaming output.

Written by Cursor Bugbot for commit 7935b21. Configure here.

@vercel
Copy link

vercel bot commented Mar 5, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Mar 5, 2026 7:05pm

Request Review

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 5, 2026

Greptile Summary

This PR introduces a shared StreamingText component backed by requestAnimationFrame batching (30 chars/frame ≈ 1800 chars/sec), replacing the old setTimeout-based character-by-character animation in the copilot's SmoothStreamingText. The new component is wired into the floating chat (message.tsx) and workspace chat (chat-message.tsx), which previously had no animation at all, and SmoothStreamingText is reduced to a thin wrapper that delegates to the shared component. StreamingIndicator is also migrated to the shared components/ui barrel.

Key observations:

  • The rAF animation loop is cancelled and restarted on every incoming streaming chunk due to the useEffect cleanup unconditionally calling cancelAnimationFrame + resetting isAnimatingRef. Since contentRef.current is updated synchronously at the top of the effect, the running loop would pick up new content naturally without being restarted — this cancel/restart cycle adds unnecessary overhead at high chunk rates.
  • The content.length === 0 early-return path does not explicitly reset isAnimatingRef.current, relying entirely on the previous effect's cleanup to do so. While correct today, adding an explicit reset would make the invariant self-evident.
  • The SmoothStreamingText wrapper retains its own memo() around StreamingText, which already has its own custom memo comparator — the outer memo is redundant but harmless.
  • All import paths and barrel exports are correctly updated.

Confidence Score: 3/5

  • Safe to merge with minor animation efficiency concerns; no data-loss or correctness bugs introduced.
  • The core streaming logic is functionally correct and the refactor cleanly deduplicates code. The main concern is a performance anti-pattern in the rAF management: the animation loop is cancelled and restarted on every content chunk rather than running continuously. At high streaming rates this creates measurable overhead. There is also a minor defensive-coding gap in the empty-content path. Neither issue causes visible breakage under normal conditions, but they are worth addressing before the component proliferates further.
  • apps/sim/components/ui/streaming-text.tsx — rAF restart-per-chunk pattern and missing isAnimatingRef reset in the empty-content guard.

Important Files Changed

Filename Overview
apps/sim/components/ui/streaming-text.tsx New shared component implementing rAF-based streaming animation. Logic is mostly correct but the useEffect cleanup cancels and restarts the rAF loop on every content chunk, adding unnecessary overhead. The empty-content early-return path also omits an explicit isAnimatingRef reset that could become confusing as the component evolves.
apps/sim/app/chat/components/message/message.tsx Adds StreamingText + conditional StreamingIndicator for floating chat assistant messages. The empty-content guard (!(cleanTextContent as string)) correctly shows the indicator only before the first chunk arrives. Import uses the correct barrel export.
apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx Replaces direct WordWrap render with StreamingText and moves StreamingIndicator to appear only when content is empty during streaming. Module-level renderWordWrap keeps the renderer reference stable across renders.
apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming/smooth-streaming.tsx Successfully stripped ~85 lines of duplicate animation logic, delegating to the new shared StreamingText. Re-exports StreamingIndicator for backward compatibility. Clean and correct.
apps/sim/components/ui/index.ts Adds barrel exports for StreamingIndicator and StreamingText — straightforward and correctly placed.
apps/sim/app/chat/hooks/use-chat-streaming.ts Cosmetic-only changes: a stale comment was removed and a blank line was added after a logger.debug call. No functional changes.

Sequence Diagram

sequenceDiagram
    participant Server
    participant useChatStreaming
    participant MessageComponent
    participant StreamingText
    participant rAF as requestAnimationFrame

    Server->>useChatStreaming: SSE chunk (contentChunk)
    useChatStreaming->>useChatStreaming: accumulatedText += chunk
    useChatStreaming->>MessageComponent: setMessages({ content: accumulatedText, isStreaming: true })
    MessageComponent->>StreamingText: content=accumulatedText, isStreaming=true
    Note over StreamingText: useEffect fires<br/>contentRef.current = content
    alt isAnimatingRef = false
        StreamingText->>rAF: requestAnimationFrame(animateText)
        StreamingText->>StreamingText: isAnimatingRef = true
    end
    rAF->>StreamingText: animateText() callback
    StreamingText->>StreamingText: slice content[0..index+CHARS_PER_FRAME]
    StreamingText->>StreamingText: setDisplayedContent(slice)
    StreamingText->>rAF: requestAnimationFrame(animateText) [loop]

    Server->>useChatStreaming: event=end / event=final
    useChatStreaming->>MessageComponent: setMessages({ isStreaming: false })
    MessageComponent->>StreamingText: isStreaming=false
    Note over StreamingText: useEffect cleanup cancels rAF<br/>setDisplayedContent(fullContent)
    StreamingText->>MessageComponent: renders complete content
Loading

Last reviewed commit: 754bf99

@waleedlatif1
Copy link
Collaborator Author

@greptile

@waleedlatif1
Copy link
Collaborator Author

@cursor review

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

@waleedlatif1
Copy link
Collaborator Author

@greptile

Comment on lines +48 to +95
useEffect(() => {
contentRef.current = content

if (content.length === 0) {
setDisplayedContent('')
indexRef.current = 0
return
}

if (isStreaming) {
if (indexRef.current < content.length) {
const animateText = () => {
const currentContent = contentRef.current
const currentIndex = indexRef.current
if (currentIndex < currentContent.length) {
const nextIndex = Math.min(currentIndex + CHARS_PER_FRAME, currentContent.length)
setDisplayedContent(currentContent.slice(0, nextIndex))
indexRef.current = nextIndex
rafRef.current = requestAnimationFrame(animateText)
} else {
isAnimatingRef.current = false
}
}

if (!isAnimatingRef.current) {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
}
isAnimatingRef.current = true
rafRef.current = requestAnimationFrame(animateText)
}
}
} else {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
}
setDisplayedContent(content)
indexRef.current = content.length
isAnimatingRef.current = false
}

return () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
}
isAnimatingRef.current = false
}
}, [content, isStreaming])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Animation loop restarted on every streaming chunk

The useEffect cleanup always calls cancelAnimationFrame(rafRef.current) and resets isAnimatingRef.current = false. This means for every incoming content chunk — which can arrive tens of times per second — the running rAF loop is cancelled and a completely new one is scheduled.

This is unnecessary because contentRef.current is already updated at the top of the effect (line 49), so any in-flight animation loop would naturally pick up the latest content on its next frame without needing a restart. The indexRef.current is also preserved across restarts, so there is no correctness gain from the cancellation cycle.

Consider removing the rAF cancellation from the cleanup and only cancelling when isStreaming transitions to false:

useEffect(() => {
  contentRef.current = content

  if (content.length === 0) {
    setDisplayedContent('')
    indexRef.current = 0
    return
  }

  if (isStreaming) {
    // contentRef is already updated above; the running loop will pick it up.
    if (!isAnimatingRef.current && indexRef.current < content.length) {
      isAnimatingRef.current = true
      rafRef.current = requestAnimationFrame(animateText)
    }
  } else {
    if (rafRef.current) cancelAnimationFrame(rafRef.current)
    rafRef.current = null
    setDisplayedContent(content)
    indexRef.current = content.length
    isAnimatingRef.current = false
  }

  return () => {
    if (!isStreaming && rafRef.current) {
      cancelAnimationFrame(rafRef.current)
    }
    isAnimatingRef.current = false
  }
}, [content, isStreaming])

This way, a single rAF loop runs continuously while streaming and simply catches up to contentRef.current on each frame, avoiding the cancel/restart overhead on every chunk update.

Comment on lines +51 to +55
if (content.length === 0) {
setDisplayedContent('')
indexRef.current = 0
return
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isAnimatingRef not explicitly reset in the empty-content path

When the early-return path is taken (content.length === 0), isAnimatingRef.current is left at whatever value it had. The cleanup registered by the previous effect run resets it to false before this effect body executes, so in practice this works correctly. However, if the component is unmounted immediately after content becomes '' — with no prior non-empty effect run — React will not execute a cleanup for this invocation (since none was returned), and isAnimatingRef may remain inconsistent.

Adding the explicit reset makes the invariant self-contained and easier to reason about:

Suggested change
if (content.length === 0) {
setDisplayedContent('')
indexRef.current = 0
return
}
if (content.length === 0) {
setDisplayedContent('')
indexRef.current = 0
isAnimatingRef.current = false
return
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant