From 64cedfcff7c6b50482080da77ea46835ae9fc634 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 9 Mar 2026 13:27:33 -0700 Subject: [PATCH] fix(streaming): smoother streaming with throttled rendering, ResizeObserver scroll, and batched updates (#3471) * fix(streaming): smoother streaming with throttled rendering, ResizeObserver scroll, and batched updates - Add useThrottledValue hook (100ms trailing-edge throttle) to gate DOM re-renders during streaming across all chat surfaces - Replace 100ms setInterval scroll polling with ResizeObserver-based auto-scroll, programmatic scroll timestamp tracking, and nested [data-scrollable] region handling - Extract processContentBuffer from inline content handler for cleaner code organization in copilot SSE handlers - Add RAF-based update batching (50ms max interval) to floating chat and home chat streaming paths - Add useProgressiveList hook for progressive rendering of long conversation histories via requestAnimationFrame Made-with: Cursor * ack PR comments * fix search modal * more comments * ack comments * count * ack comments * ack comment --- .../app/chat/components/message/message.tsx | 4 +- apps/sim/app/chat/hooks/use-chat-streaming.ts | 67 ++- .../message-content/message-content.tsx | 16 +- .../w/[workflowId]/components/chat/chat.tsx | 44 +- .../components/chat-message/chat-message.tsx | 6 +- .../components/user-input/user-input.tsx | 11 +- .../panel/components/copilot/copilot.tsx | 18 +- .../hooks/use-scroll-management.ts | 164 ++++--- .../components/search-modal/search-modal.tsx | 235 ++++++---- apps/sim/hooks/use-progressive-list.ts | 101 +++++ apps/sim/hooks/use-throttled-value.ts | 50 ++ apps/sim/lib/copilot/client-sse/handlers.ts | 428 +++++++++--------- apps/sim/stores/panel/copilot/store.ts | 5 +- apps/sim/stores/panel/copilot/types.ts | 2 - 14 files changed, 754 insertions(+), 397 deletions(-) create mode 100644 apps/sim/hooks/use-progressive-list.ts create mode 100644 apps/sim/hooks/use-throttled-value.ts diff --git a/apps/sim/app/chat/components/message/message.tsx b/apps/sim/app/chat/components/message/message.tsx index 3067692815..a9186ae020 100644 --- a/apps/sim/app/chat/components/message/message.tsx +++ b/apps/sim/app/chat/components/message/message.tsx @@ -8,6 +8,7 @@ import { ChatFileDownloadAll, } from '@/app/chat/components/message/components/file-download' import MarkdownRenderer from '@/app/chat/components/message/components/markdown-renderer' +import { useThrottledValue } from '@/hooks/use-throttled-value' export interface ChatAttachment { id: string @@ -39,7 +40,8 @@ export interface ChatMessage { } function EnhancedMarkdownRenderer({ content }: { content: string }) { - return + const throttled = useThrottledValue(content) + return } export const ClientChatMessage = memo( diff --git a/apps/sim/app/chat/hooks/use-chat-streaming.ts b/apps/sim/app/chat/hooks/use-chat-streaming.ts index e020870931..79dfb02f40 100644 --- a/apps/sim/app/chat/hooks/use-chat-streaming.ts +++ b/apps/sim/app/chat/hooks/use-chat-streaming.ts @@ -78,18 +78,15 @@ export function useChatStreaming() { abortControllerRef.current.abort() abortControllerRef.current = null - // Add a message indicating the response was stopped + const latestContent = accumulatedTextRef.current + setMessages((prev) => { const lastMessage = prev[prev.length - 1] - // Only modify if the last message is from the assistant (as expected) if (lastMessage && lastMessage.type === 'assistant') { - // Append a note that the response was stopped + const content = latestContent || lastMessage.content const updatedContent = - lastMessage.content + - (lastMessage.content - ? '\n\n_Response stopped by user._' - : '_Response stopped by user._') + content + (content ? '\n\n_Response stopped by user._' : '_Response stopped by user._') return [ ...prev.slice(0, -1), @@ -100,7 +97,6 @@ export function useChatStreaming() { return prev }) - // Reset streaming state immediately setIsStreamingResponse(false) accumulatedTextRef.current = '' lastStreamedPositionRef.current = 0 @@ -139,9 +135,49 @@ export function useChatStreaming() { let accumulatedText = '' let lastAudioPosition = 0 - // Track which blocks have streamed content (like chat panel) const messageIdMap = new Map() const messageId = crypto.randomUUID() + + const UI_BATCH_MAX_MS = 50 + let uiDirty = false + let uiRAF: number | null = null + let uiTimer: ReturnType | null = null + let lastUIFlush = 0 + + const flushUI = () => { + if (uiRAF !== null) { + cancelAnimationFrame(uiRAF) + uiRAF = null + } + if (uiTimer !== null) { + clearTimeout(uiTimer) + uiTimer = null + } + if (!uiDirty) return + uiDirty = false + lastUIFlush = performance.now() + const snapshot = accumulatedText + setMessages((prev) => + prev.map((msg) => { + if (msg.id !== messageId) return msg + if (!msg.isStreaming) return msg + return { ...msg, content: snapshot } + }) + ) + } + + const scheduleUIFlush = () => { + if (uiRAF !== null) return + const elapsed = performance.now() - lastUIFlush + if (elapsed >= UI_BATCH_MAX_MS) { + flushUI() + return + } + uiRAF = requestAnimationFrame(flushUI) + if (uiTimer === null) { + uiTimer = setTimeout(flushUI, Math.max(0, UI_BATCH_MAX_MS - elapsed)) + } + } setMessages((prev) => [ ...prev, { @@ -165,6 +201,7 @@ export function useChatStreaming() { const { done, value } = await reader.read() if (done) { + flushUI() // Stream any remaining text for TTS if ( shouldPlayAudio && @@ -217,6 +254,7 @@ export function useChatStreaming() { } if (eventType === 'final' && json.data) { + flushUI() const finalData = json.data as { success: boolean error?: string | { message?: string } @@ -367,6 +405,7 @@ export function useChatStreaming() { } accumulatedText += contentChunk + accumulatedTextRef.current = accumulatedText logger.debug('[useChatStreaming] Received chunk', { blockId, chunkLength: contentChunk.length, @@ -374,11 +413,8 @@ export function useChatStreaming() { messageId, chunk: contentChunk.substring(0, 20), }) - setMessages((prev) => - prev.map((msg) => - msg.id === messageId ? { ...msg, content: accumulatedText } : msg - ) - ) + uiDirty = true + scheduleUIFlush() // Real-time TTS for voice mode if (shouldPlayAudio && streamingOptions?.audioStreamHandler) { @@ -419,10 +455,13 @@ export function useChatStreaming() { } } catch (error) { logger.error('Error processing stream:', error) + flushUI() setMessages((prev) => prev.map((msg) => (msg.id === messageId ? { ...msg, isStreaming: false } : msg)) ) } finally { + if (uiRAF !== null) cancelAnimationFrame(uiRAF) + if (uiTimer !== null) clearTimeout(uiTimer) setIsStreamingResponse(false) abortControllerRef.current = null diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 0a032a96f7..f7af256b0b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -3,6 +3,7 @@ import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { cn } from '@/lib/core/utils/cn' +import { useThrottledValue } from '@/hooks/use-throttled-value' import type { ContentBlock, ToolCallStatus } from '../../types' const REMARK_PLUGINS = [remarkGfm] @@ -96,6 +97,15 @@ function parseBlocks(blocks: ContentBlock[], isStreaming: boolean): MessageSegme return segments } +function ThrottledTextSegment({ content }: { content: string }) { + const throttled = useThrottledValue(content) + return ( +
+ {throttled} +
+ ) +} + interface MessageContentProps { blocks: ContentBlock[] fallbackContent: string @@ -118,11 +128,7 @@ export function MessageContent({ blocks, fallbackContent, isStreaming }: Message
{segments.map((segment, i) => { if (segment.type === 'text') { - return ( -
- {segment.content} -
- ) + return } return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index 35ec02c352..4b198bc8cd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -522,10 +522,46 @@ export function Chat() { let accumulatedContent = '' let buffer = '' + const BATCH_MAX_MS = 50 + let pendingChunks = '' + let batchRAF: number | null = null + let batchTimer: ReturnType | null = null + let lastFlush = 0 + + const flushChunks = () => { + if (batchRAF !== null) { + cancelAnimationFrame(batchRAF) + batchRAF = null + } + if (batchTimer !== null) { + clearTimeout(batchTimer) + batchTimer = null + } + if (pendingChunks) { + appendMessageContent(responseMessageId, pendingChunks) + pendingChunks = '' + } + lastFlush = performance.now() + } + + const scheduleFlush = () => { + if (batchRAF !== null) return + const elapsed = performance.now() - lastFlush + if (elapsed >= BATCH_MAX_MS) { + flushChunks() + return + } + batchRAF = requestAnimationFrame(flushChunks) + if (batchTimer === null) { + batchTimer = setTimeout(flushChunks, Math.max(0, BATCH_MAX_MS - elapsed)) + } + } + try { while (true) { const { done, value } = await reader.read() if (done) { + flushChunks() finalizeMessageStream(responseMessageId) break } @@ -558,6 +594,7 @@ export function Chat() { if ('success' in result && !result.success) { const errorMessage = result.error || 'Workflow execution failed' + flushChunks() appendMessageContent( responseMessageId, `${accumulatedContent ? '\n\n' : ''}Error: ${errorMessage}` @@ -566,10 +603,12 @@ export function Chat() { return } + flushChunks() finalizeMessageStream(responseMessageId) } else if (contentChunk) { accumulatedContent += contentChunk - appendMessageContent(responseMessageId, contentChunk) + pendingChunks += contentChunk + scheduleFlush() } } catch (e) { logger.error('Error parsing stream data:', e) @@ -580,8 +619,11 @@ export function Chat() { if ((error as Error)?.name !== 'AbortError') { logger.error('Error processing stream:', error) } + flushChunks() finalizeMessageStream(responseMessageId) } finally { + if (batchRAF !== null) cancelAnimationFrame(batchRAF) + if (batchTimer !== null) clearTimeout(batchTimer) if (streamReaderRef.current === reader) { streamReaderRef.current = null } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx index a11983b0be..19ce984fbb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx @@ -1,5 +1,6 @@ import { useMemo } from 'react' import { StreamingIndicator } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming' +import { useThrottledValue } from '@/hooks/use-throttled-value' interface ChatAttachment { id: string @@ -93,13 +94,16 @@ const WordWrap = ({ text }: { text: string }) => { * Renders a chat message with optional file attachments */ export function ChatMessage({ message }: ChatMessageProps) { - const formattedContent = useMemo(() => { + const rawContent = useMemo(() => { if (typeof message.content === 'object' && message.content !== null) { return JSON.stringify(message.content, null, 2) } return String(message.content || '') }, [message.content]) + const throttled = useThrottledValue(rawContent) + const formattedContent = message.type === 'user' ? rawContent : throttled + const handleAttachmentClick = (attachment: ChatAttachment) => { const validDataUrl = attachment.dataUrl?.trim() if (validDataUrl?.startsWith('data:')) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx index ea985d4982..10d67203e2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx @@ -127,12 +127,13 @@ const UserInput = forwardRef( const params = useParams() const workspaceId = params.workspaceId as string - const copilotStore = useCopilotStore() - const workflowId = - workflowIdOverride !== undefined ? workflowIdOverride : copilotStore.workflowId + const storeWorkflowId = useCopilotStore((s) => s.workflowId) + const storeSelectedModel = useCopilotStore((s) => s.selectedModel) + const storeSetSelectedModel = useCopilotStore((s) => s.setSelectedModel) + const workflowId = workflowIdOverride !== undefined ? workflowIdOverride : storeWorkflowId const selectedModel = - selectedModelOverride !== undefined ? selectedModelOverride : copilotStore.selectedModel - const setSelectedModel = onModelChangeOverride || copilotStore.setSelectedModel + selectedModelOverride !== undefined ? selectedModelOverride : storeSelectedModel + const setSelectedModel = onModelChangeOverride || storeSetSelectedModel const [internalMessage, setInternalMessage] = useState('') const [isNearTop, setIsNearTop] = useState(false) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx index 79126b5288..68bb20b63d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx @@ -40,6 +40,7 @@ import { useTodoManagement, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/hooks' import { useScrollManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' +import { useProgressiveList } from '@/hooks/use-progressive-list' import type { ChatContext } from '@/stores/panel' import { useCopilotStore } from '@/stores/panel' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -90,7 +91,6 @@ export const Copilot = forwardRef(({ panelWidth }, ref isSendingMessage, isAborting, mode, - inputValue, planTodos, showPlanTodos, streamingPlanContent, @@ -98,7 +98,6 @@ export const Copilot = forwardRef(({ panelWidth }, ref abortMessage, createNewChat, setMode, - setInputValue, chatsLoadedForWorkflow, setWorkflowId: setCopilotWorkflowId, loadChats, @@ -116,6 +115,8 @@ export const Copilot = forwardRef(({ panelWidth }, ref resumeActiveStream, } = useCopilotStore() + const [inputValue, setInputValue] = useState('') + // Initialize copilot const { isInitialized } = useCopilotInitialization({ activeWorkflowId, @@ -133,6 +134,9 @@ export const Copilot = forwardRef(({ panelWidth }, ref // Handle scroll management const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage) + const chatKey = currentChat?.id ?? '' + const { staged: stagedMessages } = useProgressiveList(messages, chatKey) + // Handle chat history grouping const { groupedChats, handleHistoryDropdownOpen: handleHistoryDropdownOpenHook } = useChatHistory( { @@ -468,19 +472,21 @@ export const Copilot = forwardRef(({ panelWidth }, ref showPlanTodos && planTodos.length > 0 ? 'pb-14' : 'pb-10' }`} > - {messages.map((message, index) => { + {stagedMessages.map((message, index) => { let isDimmed = false + const globalIndex = messages.length - stagedMessages.length + index + if (editingMessageId) { const editingIndex = messages.findIndex((m) => m.id === editingMessageId) - isDimmed = editingIndex !== -1 && index > editingIndex + isDimmed = editingIndex !== -1 && globalIndex > editingIndex } if (!isDimmed && revertingMessageId) { const revertingIndex = messages.findIndex( (m) => m.id === revertingMessageId ) - isDimmed = revertingIndex !== -1 && index > revertingIndex + isDimmed = revertingIndex !== -1 && globalIndex > revertingIndex } const checkpointCount = messageCheckpoints[message.id]?.length || 0 @@ -501,7 +507,7 @@ export const Copilot = forwardRef(({ panelWidth }, ref onRevertModeChange={(isReverting) => handleRevertModeChange(message.id, isReverting) } - isLastMessage={index === messages.length - 1} + isLastMessage={globalIndex === messages.length - 1} /> ) })} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-scroll-management.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-scroll-management.ts index 9a330475dc..620c75e37e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-scroll-management.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-scroll-management.ts @@ -2,33 +2,34 @@ import { useCallback, useEffect, useRef, useState } from 'react' -/** - * Options for configuring scroll behavior - */ +const AUTO_SCROLL_GRACE_MS = 120 + +function distanceFromBottom(el: HTMLElement) { + return el.scrollHeight - el.scrollTop - el.clientHeight +} + interface UseScrollManagementOptions { /** - * Scroll behavior for programmatic scrolls - * @remarks + * Scroll behavior for programmatic scrolls. * - `smooth`: Animated scroll (default, used by Copilot) * - `auto`: Immediate scroll to bottom (used by floating chat to avoid jitter) */ behavior?: 'auto' | 'smooth' /** - * Distance from bottom (in pixels) within which auto-scroll stays active - * @remarks Lower values = less sticky (user can scroll away easier) + * Distance from bottom (in pixels) within which auto-scroll stays active. * @defaultValue 30 */ stickinessThreshold?: number } /** - * Custom hook to manage scroll behavior in scrollable message panels. - * Handles auto-scrolling during message streaming and user-initiated scrolling. + * Manages auto-scrolling during message streaming using ResizeObserver + * instead of a polling interval. * - * @param messages - Array of messages to track for scroll behavior - * @param isSendingMessage - Whether a message is currently being sent/streamed - * @param options - Optional configuration for scroll behavior - * @returns Scroll management utilities + * Tracks whether scrolls are programmatic (via a timestamp grace window) + * to avoid falsely treating our own scrolls as the user scrolling away. + * Handles nested scrollable regions marked with `data-scrollable` so that + * scrolling inside tool output or code blocks doesn't break follow-mode. */ export function useScrollManagement( messages: any[], @@ -37,68 +38,98 @@ export function useScrollManagement( ) { const scrollAreaRef = useRef(null) const [userHasScrolledAway, setUserHasScrolledAway] = useState(false) - const programmaticScrollRef = useRef(false) + const programmaticUntilRef = useRef(0) const lastScrollTopRef = useRef(0) const scrollBehavior = options?.behavior ?? 'smooth' const stickinessThreshold = options?.stickinessThreshold ?? 30 - /** Scrolls the container to the bottom */ + const isSendingRef = useRef(isSendingMessage) + isSendingRef.current = isSendingMessage + const userScrolledRef = useRef(userHasScrolledAway) + userScrolledRef.current = userHasScrolledAway + + const markProgrammatic = useCallback(() => { + programmaticUntilRef.current = Date.now() + AUTO_SCROLL_GRACE_MS + }, []) + + const isProgrammatic = useCallback(() => { + return Date.now() < programmaticUntilRef.current + }, []) + const scrollToBottom = useCallback(() => { const container = scrollAreaRef.current if (!container) return - programmaticScrollRef.current = true + markProgrammatic() container.scrollTo({ top: container.scrollHeight, behavior: scrollBehavior }) + }, [scrollBehavior, markProgrammatic]) - window.setTimeout(() => { - programmaticScrollRef.current = false - }, 200) - }, [scrollBehavior]) - - /** Handles scroll events to track user position */ - const handleScroll = useCallback(() => { - const container = scrollAreaRef.current - if (!container || programmaticScrollRef.current) return - - const { scrollTop, scrollHeight, clientHeight } = container - const distanceFromBottom = scrollHeight - scrollTop - clientHeight - const nearBottom = distanceFromBottom <= stickinessThreshold - const delta = scrollTop - lastScrollTopRef.current - - if (isSendingMessage) { - // User scrolled up during streaming - break away - if (delta < -2) { - setUserHasScrolledAway(true) - } - // User scrolled back down to bottom - re-stick - if (userHasScrolledAway && delta > 2 && nearBottom) { - setUserHasScrolledAway(false) - } - } - - lastScrollTopRef.current = scrollTop - }, [isSendingMessage, userHasScrolledAway, stickinessThreshold]) - - /** Attaches scroll listener to container */ useEffect(() => { const container = scrollAreaRef.current if (!container) return + const handleScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = container + const dist = scrollHeight - scrollTop - clientHeight + + if (isProgrammatic()) { + lastScrollTopRef.current = scrollTop + if (dist < stickinessThreshold && userScrolledRef.current) { + setUserHasScrolledAway(false) + } + return + } + + const nearBottom = dist <= stickinessThreshold + const delta = scrollTop - lastScrollTopRef.current + + if (isSendingRef.current) { + if (delta < -2 && !userScrolledRef.current) { + setUserHasScrolledAway(true) + } + if (userScrolledRef.current && delta > 2 && nearBottom) { + setUserHasScrolledAway(false) + } + } + + lastScrollTopRef.current = scrollTop + } + container.addEventListener('scroll', handleScroll, { passive: true }) lastScrollTopRef.current = container.scrollTop return () => container.removeEventListener('scroll', handleScroll) - }, [handleScroll]) + }, [stickinessThreshold, isProgrammatic]) + + // Ignore upward wheel events inside nested [data-scrollable] regions + // (tool output, code blocks) so they don't break follow-mode. + useEffect(() => { + const container = scrollAreaRef.current + if (!container) return + + const handleWheel = (e: WheelEvent) => { + if (e.deltaY >= 0) return + + const target = e.target instanceof Element ? e.target : undefined + const nested = target?.closest('[data-scrollable]') + if (nested && nested !== container) return + + if (!userScrolledRef.current && isSendingRef.current) { + setUserHasScrolledAway(true) + } + } + + container.addEventListener('wheel', handleWheel, { passive: true }) + return () => container.removeEventListener('wheel', handleWheel) + }, []) - /** Handles auto-scroll when new messages are added */ useEffect(() => { if (messages.length === 0) return const lastMessage = messages[messages.length - 1] const isUserMessage = lastMessage?.role === 'user' - // Always scroll for user messages, respect scroll state for assistant messages if (isUserMessage) { setUserHasScrolledAway(false) scrollToBottom() @@ -107,35 +138,42 @@ export function useScrollManagement( } }, [messages, userHasScrolledAway, scrollToBottom]) - /** Resets scroll state when streaming completes */ useEffect(() => { if (!isSendingMessage) { setUserHasScrolledAway(false) } }, [isSendingMessage]) - /** Keeps scroll pinned during streaming - uses interval, stops when user scrolls away */ useEffect(() => { - // Early return stops the interval when user scrolls away (state change re-runs effect) - if (!isSendingMessage || userHasScrolledAway) { - return - } + if (!isSendingMessage || userHasScrolledAway) return - const intervalId = window.setInterval(() => { - const container = scrollAreaRef.current - if (!container) return + const container = scrollAreaRef.current + if (!container) return - const { scrollTop, scrollHeight, clientHeight } = container - const distanceFromBottom = scrollHeight - scrollTop - clientHeight + const content = container.firstElementChild as HTMLElement | null + if (!content) return - if (distanceFromBottom > 1) { + const observer = new ResizeObserver(() => { + if (distanceFromBottom(container) > 1) { scrollToBottom() } - }, 100) + }) - return () => window.clearInterval(intervalId) + observer.observe(content) + + return () => observer.disconnect() }, [isSendingMessage, userHasScrolledAway, scrollToBottom]) + // overflow-anchor: none during streaming prevents the browser from + // fighting our programmatic scrollToBottom calls (Chromium/Firefox only; + // Safari does not support this property). + useEffect(() => { + const container = scrollAreaRef.current + if (!container) return + + container.style.overflowAnchor = isSendingMessage && !userHasScrolledAway ? 'none' : 'auto' + }, [isSendingMessage, userHasScrolledAway]) + return { scrollAreaRef, scrollToBottom, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index cf306a315f..fb4c2e973e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' import { Command } from 'cmdk' import { Database, Files, HelpCircle, Settings } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' @@ -19,23 +19,34 @@ import type { SearchToolOperationItem, } from '@/stores/modals/search/types' -function customFilter(value: string, search: string): number { - const searchLower = search.toLowerCase() +function scoreMatch(value: string, search: string): number { + if (!search) return 1 const valueLower = value.toLowerCase() + const searchLower = search.toLowerCase() if (valueLower === searchLower) return 1 if (valueLower.startsWith(searchLower)) return 0.9 if (valueLower.includes(searchLower)) return 0.7 - const searchWords = searchLower.split(/\s+/).filter(Boolean) - if (searchWords.length > 1) { - const allWordsMatch = searchWords.every((word) => valueLower.includes(word)) - if (allWordsMatch) return 0.5 + const words = searchLower.split(/\s+/).filter(Boolean) + if (words.length > 1) { + if (words.every((w) => valueLower.includes(w))) return 0.5 } return 0 } +function filterAndSort(items: T[], toValue: (item: T) => string, search: string): T[] { + if (!search) return items + const scored: [T, number][] = [] + for (const item of items) { + const s = scoreMatch(toValue(item), search) + if (s > 0) scored.push([item, s]) + } + scored.sort((a, b) => b[1] - a[1]) + return scored.map(([item]) => item) +} + interface TaskItem { id: string name: string @@ -165,20 +176,27 @@ export function SearchModal({ ) useEffect(() => { - if (open && inputRef.current) { - const nativeInputValueSetter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - 'value' - )?.set - if (nativeInputValueSetter) { - nativeInputValueSetter.call(inputRef.current, '') - inputRef.current.dispatchEvent(new Event('input', { bubbles: true })) + if (open) { + setSearch('') + if (inputRef.current) { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value' + )?.set + if (nativeInputValueSetter) { + nativeInputValueSetter.call(inputRef.current, '') + inputRef.current.dispatchEvent(new Event('input', { bubbles: true })) + } + inputRef.current.focus() } - inputRef.current.focus() } }, [open]) - const handleSearchChange = useCallback(() => { + const [search, setSearch] = useState('') + const deferredSearch = useDeferredValue(search) + + const handleSearchChange = useCallback((value: string) => { + setSearch(value) requestAnimationFrame(() => { const list = document.querySelector('[cmdk-list]') if (list) { @@ -274,11 +292,51 @@ export function SearchModal({ [onOpenChange] ) - const showBlocks = isOnWorkflowPage && blocks.length > 0 - const showTools = isOnWorkflowPage && tools.length > 0 - const showTriggers = isOnWorkflowPage && triggers.length > 0 - const showToolOperations = isOnWorkflowPage && toolOperations.length > 0 - const showDocs = isOnWorkflowPage && docs.length > 0 + const filteredBlocks = useMemo(() => { + if (!isOnWorkflowPage) return [] + return filterAndSort(blocks, (b) => `${b.name} block-${b.id}`, deferredSearch) + }, [isOnWorkflowPage, blocks, deferredSearch]) + + const filteredTools = useMemo(() => { + if (!isOnWorkflowPage) return [] + return filterAndSort(tools, (t) => `${t.name} tool-${t.id}`, deferredSearch) + }, [isOnWorkflowPage, tools, deferredSearch]) + + const filteredTriggers = useMemo(() => { + if (!isOnWorkflowPage) return [] + return filterAndSort(triggers, (t) => `${t.name} trigger-${t.id}`, deferredSearch) + }, [isOnWorkflowPage, triggers, deferredSearch]) + + const filteredToolOps = useMemo(() => { + if (!isOnWorkflowPage) return [] + return filterAndSort( + toolOperations, + (op) => `${op.searchValue} operation-${op.id}`, + deferredSearch + ) + }, [isOnWorkflowPage, toolOperations, deferredSearch]) + + const filteredDocs = useMemo(() => { + if (!isOnWorkflowPage) return [] + return filterAndSort(docs, (d) => `${d.name} docs documentation doc-${d.id}`, deferredSearch) + }, [isOnWorkflowPage, docs, deferredSearch]) + + const filteredWorkflows = useMemo( + () => filterAndSort(workflows, (w) => `${w.name} workflow-${w.id}`, deferredSearch), + [workflows, deferredSearch] + ) + const filteredTasks = useMemo( + () => filterAndSort(tasks, (t) => `${t.name} task-${t.id}`, deferredSearch), + [tasks, deferredSearch] + ) + const filteredWorkspaces = useMemo( + () => filterAndSort(workspaces, (w) => `${w.name} workspace-${w.id}`, deferredSearch), + [workspaces, deferredSearch] + ) + const filteredPages = useMemo( + () => filterAndSort(pages, (p) => `${p.name} page-${p.id}`, deferredSearch), + [pages, deferredSearch] + ) if (!mounted) return null @@ -294,7 +352,6 @@ export function SearchModal({ aria-hidden={!open} /> - {/* Command palette - always rendered for instant opening, hidden with CSS */}
- + - {showBlocks && ( + {filteredBlocks.length > 0 && ( - {blocks.map((block) => ( - ( + handleBlockSelect(block, 'block')} @@ -331,15 +388,15 @@ export function SearchModal({ showColoredIcon > {block.name} - + ))} )} - {showTools && ( + {filteredTools.length > 0 && ( - {tools.map((tool) => ( - ( + handleBlockSelect(tool, 'tool')} @@ -348,15 +405,15 @@ export function SearchModal({ showColoredIcon > {tool.name} - + ))} )} - {showTriggers && ( + {filteredTriggers.length > 0 && ( - {triggers.map((trigger) => ( - ( + handleBlockSelect(trigger, 'trigger')} @@ -365,14 +422,14 @@ export function SearchModal({ showColoredIcon > {trigger.name} - + ))} )} - {workflows.length > 0 && ( + {filteredWorkflows.length > 0 && open && ( - {workflows.map((workflow) => ( + {filteredWorkflows.map((workflow) => ( )} - {tasks.length > 0 && ( + {filteredTasks.length > 0 && open && ( - {tasks.map((task) => ( + {filteredTasks.map((task) => ( )} - {showToolOperations && ( + {filteredToolOps.length > 0 && ( - {toolOperations.map((op) => ( - ( + handleToolOperationSelect(op)} @@ -431,14 +488,14 @@ export function SearchModal({ showColoredIcon > {op.name} - + ))} )} - {workspaces.length > 0 && ( + {filteredWorkspaces.length > 0 && open && ( - {workspaces.map((workspace) => ( + {filteredWorkspaces.map((workspace) => ( )} - {showDocs && ( + {filteredDocs.length > 0 && ( - {docs.map((doc) => ( - ( + handleDocSelect(doc)} @@ -466,14 +523,14 @@ export function SearchModal({ showColoredIcon > {doc.name} - + ))} )} - {pages.length > 0 && ( + {filteredPages.length > 0 && open && ( - {pages.map((page) => { + {filteredPages.map((page) => { const Icon = page.icon return ( -
- -
- - {children} - -
- ) -} +
+ +
+ + {children} + +
+ ) + }, + (prev, next) => + prev.value === next.value && + prev.icon === next.icon && + prev.bgColor === next.bgColor && + prev.showColoredIcon === next.showColoredIcon && + prev.children === next.children +) diff --git a/apps/sim/hooks/use-progressive-list.ts b/apps/sim/hooks/use-progressive-list.ts new file mode 100644 index 0000000000..74d7dc87a9 --- /dev/null +++ b/apps/sim/hooks/use-progressive-list.ts @@ -0,0 +1,101 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' + +interface ProgressiveListOptions { + /** Number of items to render in the initial batch (most recent items) */ + initialBatch?: number + /** Number of items to add per animation frame */ + batchSize?: number +} + +const DEFAULTS = { + initialBatch: 10, + batchSize: 5, +} satisfies Required + +/** + * Progressively renders a list of items so that first paint is fast. + * + * On mount (or when `key` changes), only the most recent `initialBatch` + * items are rendered. The rest are added in `batchSize` increments via + * `requestAnimationFrame` so the browser never blocks on a large DOM mount. + * + * Once staging completes for a given key it never re-stages -- new items + * appended to the list are rendered immediately. + * + * @param items Full list of items to render. + * @param key A session/conversation identifier. When it changes, + * staging restarts for the new list. + * @param options Tuning knobs for batch sizes. + * @returns The currently staged (visible) subset of items. + */ +export function useProgressiveList( + items: T[], + key: string, + options?: ProgressiveListOptions +): { staged: T[]; isStaging: boolean } { + const initialBatch = options?.initialBatch ?? DEFAULTS.initialBatch + const batchSize = options?.batchSize ?? DEFAULTS.batchSize + + const completedKeysRef = useRef(new Set()) + const prevKeyRef = useRef(key) + const stagingCountRef = useRef(initialBatch) + const [count, setCount] = useState(() => { + if (items.length <= initialBatch) return items.length + return initialBatch + }) + + useEffect(() => { + if (completedKeysRef.current.has(key)) { + setCount(items.length) + return + } + + if (items.length <= initialBatch) { + setCount(items.length) + completedKeysRef.current.add(key) + return + } + + let current = Math.max(stagingCountRef.current, initialBatch) + setCount(current) + + let frame: number | undefined + + const step = () => { + const total = items.length + current = Math.min(total, current + batchSize) + stagingCountRef.current = current + setCount(current) + if (current >= total) { + completedKeysRef.current.add(key) + frame = undefined + return + } + frame = requestAnimationFrame(step) + } + + frame = requestAnimationFrame(step) + + return () => { + if (frame !== undefined) cancelAnimationFrame(frame) + } + }, [key, items.length, initialBatch, batchSize]) + + let effectiveCount = count + if (prevKeyRef.current !== key) { + effectiveCount = items.length <= initialBatch ? items.length : initialBatch + stagingCountRef.current = initialBatch + } + prevKeyRef.current = key + + const isCompleted = completedKeysRef.current.has(key) + const isStaging = !isCompleted && effectiveCount < items.length + const staged = + isCompleted || effectiveCount >= items.length + ? items + : items.slice(Math.max(0, items.length - effectiveCount)) + + return { staged, isStaging } +} diff --git a/apps/sim/hooks/use-throttled-value.ts b/apps/sim/hooks/use-throttled-value.ts new file mode 100644 index 0000000000..7fe668f66a --- /dev/null +++ b/apps/sim/hooks/use-throttled-value.ts @@ -0,0 +1,50 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' + +const TEXT_RENDER_THROTTLE_MS = 100 + +/** + * Trailing-edge throttle for rendered string values. + * + * The underlying data accumulates instantly via the caller's state, but this + * hook gates DOM re-renders to at most every {@link TEXT_RENDER_THROTTLE_MS}ms. + * When streaming stops (i.e. the value settles), the final value is flushed + * immediately so no trailing content is lost. + */ +export function useThrottledValue(value: string): string { + const [displayed, setDisplayed] = useState(value) + const lastFlushRef = useRef(0) + const timerRef = useRef | undefined>(undefined) + + useEffect(() => { + const now = Date.now() + const remaining = TEXT_RENDER_THROTTLE_MS - (now - lastFlushRef.current) + + if (remaining <= 0) { + if (timerRef.current !== undefined) { + clearTimeout(timerRef.current) + timerRef.current = undefined + } + lastFlushRef.current = now + setDisplayed(value) + } else { + if (timerRef.current !== undefined) clearTimeout(timerRef.current) + + timerRef.current = setTimeout(() => { + lastFlushRef.current = Date.now() + setDisplayed(value) + timerRef.current = undefined + }, remaining) + } + + return () => { + if (timerRef.current !== undefined) { + clearTimeout(timerRef.current) + timerRef.current = undefined + } + } + }, [value]) + + return displayed +} diff --git a/apps/sim/lib/copilot/client-sse/handlers.ts b/apps/sim/lib/copilot/client-sse/handlers.ts index b80163d51d..86225445b6 100644 --- a/apps/sim/lib/copilot/client-sse/handlers.ts +++ b/apps/sim/lib/copilot/client-sse/handlers.ts @@ -199,6 +199,222 @@ function appendThinkingContent(context: ClientStreamingContext, text: string) { context.currentTextBlock = null } +function processContentBuffer( + context: ClientStreamingContext, + get: () => CopilotStore, + set: StoreSet +) { + let contentToProcess = context.pendingContent + let hasProcessedContent = false + + const thinkingStartRegex = // + const thinkingEndRegex = /<\/thinking>/ + const designWorkflowStartRegex = // + const designWorkflowEndRegex = /<\/design_workflow>/ + + const splitTrailingPartialTag = ( + text: string, + tags: string[] + ): { text: string; remaining: string } => { + const partialIndex = text.lastIndexOf('<') + if (partialIndex < 0) { + return { text, remaining: '' } + } + const possibleTag = text.substring(partialIndex) + const matchesTagStart = tags.some((tag) => tag.startsWith(possibleTag)) + if (!matchesTagStart) { + return { text, remaining: '' } + } + return { + text: text.substring(0, partialIndex), + remaining: possibleTag, + } + } + + while (contentToProcess.length > 0) { + if (context.isInDesignWorkflowBlock) { + const endMatch = designWorkflowEndRegex.exec(contentToProcess) + if (endMatch) { + const designContent = contentToProcess.substring(0, endMatch.index) + context.designWorkflowContent += designContent + context.isInDesignWorkflowBlock = false + + logger.info('[design_workflow] Tag complete, setting plan content', { + contentLength: context.designWorkflowContent.length, + }) + set({ streamingPlanContent: context.designWorkflowContent }) + + contentToProcess = contentToProcess.substring(endMatch.index + endMatch[0].length) + hasProcessedContent = true + } else { + const { text, remaining } = splitTrailingPartialTag(contentToProcess, [ + '', + ]) + context.designWorkflowContent += text + + set({ streamingPlanContent: context.designWorkflowContent }) + + contentToProcess = remaining + hasProcessedContent = true + if (remaining) { + break + } + } + continue + } + + if (!context.isInThinkingBlock && !context.isInDesignWorkflowBlock) { + const designStartMatch = designWorkflowStartRegex.exec(contentToProcess) + if (designStartMatch) { + const textBeforeDesign = contentToProcess.substring(0, designStartMatch.index) + if (textBeforeDesign) { + appendTextBlock(context, textBeforeDesign) + hasProcessedContent = true + } + context.isInDesignWorkflowBlock = true + context.designWorkflowContent = '' + contentToProcess = contentToProcess.substring( + designStartMatch.index + designStartMatch[0].length + ) + hasProcessedContent = true + continue + } + + const nextMarkIndex = contentToProcess.indexOf('') + const nextCheckIndex = contentToProcess.indexOf('') + const hasMark = nextMarkIndex >= 0 + const hasCheck = nextCheckIndex >= 0 + + const nextTagIndex = + hasMark && hasCheck + ? Math.min(nextMarkIndex, nextCheckIndex) + : hasMark + ? nextMarkIndex + : hasCheck + ? nextCheckIndex + : -1 + + if (nextTagIndex >= 0) { + const isMarkTodo = hasMark && nextMarkIndex === nextTagIndex + const tagStart = isMarkTodo ? '' : '' + const tagEnd = isMarkTodo ? '' : '' + const closingIndex = contentToProcess.indexOf(tagEnd, nextTagIndex + tagStart.length) + + if (closingIndex === -1) { + break + } + + const todoId = contentToProcess + .substring(nextTagIndex + tagStart.length, closingIndex) + .trim() + logger.info( + isMarkTodo ? '[TODO] Detected marktodo tag' : '[TODO] Detected checkofftodo tag', + { todoId } + ) + + if (todoId) { + try { + get().updatePlanTodoStatus(todoId, isMarkTodo ? 'executing' : 'completed') + logger.info( + isMarkTodo + ? '[TODO] Successfully marked todo in progress' + : '[TODO] Successfully checked off todo', + { todoId } + ) + } catch (e) { + logger.error( + isMarkTodo + ? '[TODO] Failed to mark todo in progress' + : '[TODO] Failed to checkoff todo', + { todoId, error: e } + ) + } + } else { + logger.warn('[TODO] Empty todoId extracted from todo tag', { tagType: tagStart }) + } + + let beforeTag = contentToProcess.substring(0, nextTagIndex) + let afterTag = contentToProcess.substring(closingIndex + tagEnd.length) + + const hadNewlineBefore = /(\r?\n)+$/.test(beforeTag) + const hadNewlineAfter = /^(\r?\n)+/.test(afterTag) + + beforeTag = beforeTag.replace(/(\r?\n)+$/, '') + afterTag = afterTag.replace(/^(\r?\n)+/, '') + + contentToProcess = beforeTag + (hadNewlineBefore && hadNewlineAfter ? '\n' : '') + afterTag + context.currentTextBlock = null + hasProcessedContent = true + continue + } + } + + if (context.isInThinkingBlock) { + const endMatch = thinkingEndRegex.exec(contentToProcess) + if (endMatch) { + const thinkingContent = contentToProcess.substring(0, endMatch.index) + appendThinkingContent(context, thinkingContent) + finalizeThinkingBlock(context) + contentToProcess = contentToProcess.substring(endMatch.index + endMatch[0].length) + hasProcessedContent = true + } else { + const { text, remaining } = splitTrailingPartialTag(contentToProcess, ['']) + if (text) { + appendThinkingContent(context, text) + hasProcessedContent = true + } + contentToProcess = remaining + if (remaining) { + break + } + } + } else { + const startMatch = thinkingStartRegex.exec(contentToProcess) + if (startMatch) { + const textBeforeThinking = contentToProcess.substring(0, startMatch.index) + if (textBeforeThinking) { + appendTextBlock(context, textBeforeThinking) + hasProcessedContent = true + } + context.isInThinkingBlock = true + context.currentTextBlock = null + contentToProcess = contentToProcess.substring(startMatch.index + startMatch[0].length) + hasProcessedContent = true + } else { + let partialTagIndex = contentToProcess.lastIndexOf('<') + + const partialMarkTodo = contentToProcess.lastIndexOf(' partialTagIndex) { + partialTagIndex = partialMarkTodo + } + if (partialCheckoffTodo > partialTagIndex) { + partialTagIndex = partialCheckoffTodo + } + + let textToAdd = contentToProcess + let remaining = '' + if (partialTagIndex >= 0 && partialTagIndex > contentToProcess.length - 50) { + textToAdd = contentToProcess.substring(0, partialTagIndex) + remaining = contentToProcess.substring(partialTagIndex) + } + if (textToAdd) { + appendTextBlock(context, textToAdd) + hasProcessedContent = true + } + contentToProcess = remaining + break + } + } + } + + context.pendingContent = contentToProcess + if (hasProcessedContent) { + updateStreamingMessage(set, context) + } +} + export const sseHandlers: Record = { chat_id: async (data, context, get, set) => { context.newChatId = data.chatId @@ -704,217 +920,7 @@ export const sseHandlers: Record = { content: (data, context, get, set) => { if (!data.data) return context.pendingContent += data.data - - let contentToProcess = context.pendingContent - let hasProcessedContent = false - - const thinkingStartRegex = // - const thinkingEndRegex = /<\/thinking>/ - const designWorkflowStartRegex = // - const designWorkflowEndRegex = /<\/design_workflow>/ - - const splitTrailingPartialTag = ( - text: string, - tags: string[] - ): { text: string; remaining: string } => { - const partialIndex = text.lastIndexOf('<') - if (partialIndex < 0) { - return { text, remaining: '' } - } - const possibleTag = text.substring(partialIndex) - const matchesTagStart = tags.some((tag) => tag.startsWith(possibleTag)) - if (!matchesTagStart) { - return { text, remaining: '' } - } - return { - text: text.substring(0, partialIndex), - remaining: possibleTag, - } - } - - while (contentToProcess.length > 0) { - if (context.isInDesignWorkflowBlock) { - const endMatch = designWorkflowEndRegex.exec(contentToProcess) - if (endMatch) { - const designContent = contentToProcess.substring(0, endMatch.index) - context.designWorkflowContent += designContent - context.isInDesignWorkflowBlock = false - - logger.info('[design_workflow] Tag complete, setting plan content', { - contentLength: context.designWorkflowContent.length, - }) - set({ streamingPlanContent: context.designWorkflowContent }) - - contentToProcess = contentToProcess.substring(endMatch.index + endMatch[0].length) - hasProcessedContent = true - } else { - const { text, remaining } = splitTrailingPartialTag(contentToProcess, [ - '', - ]) - context.designWorkflowContent += text - - set({ streamingPlanContent: context.designWorkflowContent }) - - contentToProcess = remaining - hasProcessedContent = true - if (remaining) { - break - } - } - continue - } - - if (!context.isInThinkingBlock && !context.isInDesignWorkflowBlock) { - const designStartMatch = designWorkflowStartRegex.exec(contentToProcess) - if (designStartMatch) { - const textBeforeDesign = contentToProcess.substring(0, designStartMatch.index) - if (textBeforeDesign) { - appendTextBlock(context, textBeforeDesign) - hasProcessedContent = true - } - context.isInDesignWorkflowBlock = true - context.designWorkflowContent = '' - contentToProcess = contentToProcess.substring( - designStartMatch.index + designStartMatch[0].length - ) - hasProcessedContent = true - continue - } - - const nextMarkIndex = contentToProcess.indexOf('') - const nextCheckIndex = contentToProcess.indexOf('') - const hasMark = nextMarkIndex >= 0 - const hasCheck = nextCheckIndex >= 0 - - const nextTagIndex = - hasMark && hasCheck - ? Math.min(nextMarkIndex, nextCheckIndex) - : hasMark - ? nextMarkIndex - : hasCheck - ? nextCheckIndex - : -1 - - if (nextTagIndex >= 0) { - const isMarkTodo = hasMark && nextMarkIndex === nextTagIndex - const tagStart = isMarkTodo ? '' : '' - const tagEnd = isMarkTodo ? '' : '' - const closingIndex = contentToProcess.indexOf(tagEnd, nextTagIndex + tagStart.length) - - if (closingIndex === -1) { - break - } - - const todoId = contentToProcess - .substring(nextTagIndex + tagStart.length, closingIndex) - .trim() - logger.info( - isMarkTodo ? '[TODO] Detected marktodo tag' : '[TODO] Detected checkofftodo tag', - { todoId } - ) - - if (todoId) { - try { - get().updatePlanTodoStatus(todoId, isMarkTodo ? 'executing' : 'completed') - logger.info( - isMarkTodo - ? '[TODO] Successfully marked todo in progress' - : '[TODO] Successfully checked off todo', - { todoId } - ) - } catch (e) { - logger.error( - isMarkTodo - ? '[TODO] Failed to mark todo in progress' - : '[TODO] Failed to checkoff todo', - { todoId, error: e } - ) - } - } else { - logger.warn('[TODO] Empty todoId extracted from todo tag', { tagType: tagStart }) - } - - let beforeTag = contentToProcess.substring(0, nextTagIndex) - let afterTag = contentToProcess.substring(closingIndex + tagEnd.length) - - const hadNewlineBefore = /(\r?\n)+$/.test(beforeTag) - const hadNewlineAfter = /^(\r?\n)+/.test(afterTag) - - beforeTag = beforeTag.replace(/(\r?\n)+$/, '') - afterTag = afterTag.replace(/^(\r?\n)+/, '') - - contentToProcess = - beforeTag + (hadNewlineBefore && hadNewlineAfter ? '\n' : '') + afterTag - context.currentTextBlock = null - hasProcessedContent = true - continue - } - } - - if (context.isInThinkingBlock) { - const endMatch = thinkingEndRegex.exec(contentToProcess) - if (endMatch) { - const thinkingContent = contentToProcess.substring(0, endMatch.index) - appendThinkingContent(context, thinkingContent) - finalizeThinkingBlock(context) - contentToProcess = contentToProcess.substring(endMatch.index + endMatch[0].length) - hasProcessedContent = true - } else { - const { text, remaining } = splitTrailingPartialTag(contentToProcess, ['']) - if (text) { - appendThinkingContent(context, text) - hasProcessedContent = true - } - contentToProcess = remaining - if (remaining) { - break - } - } - } else { - const startMatch = thinkingStartRegex.exec(contentToProcess) - if (startMatch) { - const textBeforeThinking = contentToProcess.substring(0, startMatch.index) - if (textBeforeThinking) { - appendTextBlock(context, textBeforeThinking) - hasProcessedContent = true - } - context.isInThinkingBlock = true - context.currentTextBlock = null - contentToProcess = contentToProcess.substring(startMatch.index + startMatch[0].length) - hasProcessedContent = true - } else { - let partialTagIndex = contentToProcess.lastIndexOf('<') - - const partialMarkTodo = contentToProcess.lastIndexOf(' partialTagIndex) { - partialTagIndex = partialMarkTodo - } - if (partialCheckoffTodo > partialTagIndex) { - partialTagIndex = partialCheckoffTodo - } - - let textToAdd = contentToProcess - let remaining = '' - if (partialTagIndex >= 0 && partialTagIndex > contentToProcess.length - 50) { - textToAdd = contentToProcess.substring(0, partialTagIndex) - remaining = contentToProcess.substring(partialTagIndex) - } - if (textToAdd) { - appendTextBlock(context, textToAdd) - hasProcessedContent = true - } - contentToProcess = remaining - break - } - } - } - - context.pendingContent = contentToProcess - if (hasProcessedContent) { - updateStreamingMessage(set, context) - } + processContentBuffer(context, get, set) }, done: (_data, context) => { logger.info('[SSE] DONE EVENT RECEIVED', { diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index c181cb47d5..43475e43ae 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -462,7 +462,7 @@ function prepareSendContext( if (revertState) { const currentMessages = get().messages newMessages = [...currentMessages, userMessage, streamingMessage] - set({ revertState: null, inputValue: '' }) + set({ revertState: null }) } else { const currentMessages = get().messages const existingIndex = messageId ? currentMessages.findIndex((m) => m.id === messageId) : -1 @@ -1037,7 +1037,6 @@ const initialState = { chatsLastLoadedAt: null as Date | null, chatsLoadedForWorkflow: null as string | null, revertState: null as { messageId: string; messageContent: string } | null, - inputValue: '', planTodos: [] as Array<{ id: string; content: string; completed?: boolean; executing?: boolean }>, showPlanTodos: false, streamingPlanContent: '', @@ -2222,8 +2221,6 @@ export const useCopilotStore = create()( set(initialState) }, - // Input controls - setInputValue: (value: string) => set({ inputValue: value }), clearRevertState: () => set({ revertState: null }), // Todo list (UI only) diff --git a/apps/sim/stores/panel/copilot/types.ts b/apps/sim/stores/panel/copilot/types.ts index 5f04b62aa7..205520c6e2 100644 --- a/apps/sim/stores/panel/copilot/types.ts +++ b/apps/sim/stores/panel/copilot/types.ts @@ -155,7 +155,6 @@ export interface CopilotState { chatsLoadedForWorkflow: string | null revertState: { messageId: string; messageContent: string } | null - inputValue: string planTodos: Array<{ id: string; content: string; completed?: boolean; executing?: boolean }> showPlanTodos: boolean @@ -235,7 +234,6 @@ export interface CopilotActions { cleanup: () => void reset: () => void - setInputValue: (value: string) => void clearRevertState: () => void setPlanTodos: (