{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: (