diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts
index b14feb495d..8d92bdbb7b 100644
--- a/apps/sim/app/api/copilot/chat/route.ts
+++ b/apps/sim/app/api/copilot/chat/route.ts
@@ -802,49 +802,29 @@ export async function POST(req: NextRequest) {
toolNames: toolCalls.map((tc) => tc?.name).filter(Boolean),
})
- // Save messages to database after streaming completes (including aborted messages)
+ // NOTE: Messages are saved by the client via update-messages endpoint with full contentBlocks.
+ // Server only updates conversationId here to avoid overwriting client's richer save.
if (currentChat) {
- const updatedMessages = [...conversationHistory, userMessage]
-
- // Save assistant message if there's any content or tool calls (even partial from abort)
- if (assistantContent.trim() || toolCalls.length > 0) {
- const assistantMessage = {
- id: crypto.randomUUID(),
- role: 'assistant',
- content: assistantContent,
- timestamp: new Date().toISOString(),
- ...(toolCalls.length > 0 && { toolCalls }),
- }
- updatedMessages.push(assistantMessage)
- logger.info(
- `[${tracker.requestId}] Saving assistant message with content (${assistantContent.length} chars) and ${toolCalls.length} tool calls`
- )
- } else {
- logger.info(
- `[${tracker.requestId}] No assistant content or tool calls to save (aborted before response)`
- )
- }
-
// Persist only a safe conversationId to avoid continuing from a state that expects tool outputs
const previousConversationId = currentChat?.conversationId as string | undefined
const responseId = lastSafeDoneResponseId || previousConversationId || undefined
- // Update chat in database immediately (without title)
- await db
- .update(copilotChats)
- .set({
- messages: updatedMessages,
- updatedAt: new Date(),
- ...(responseId ? { conversationId: responseId } : {}),
- })
- .where(eq(copilotChats.id, actualChatId!))
+ if (responseId) {
+ await db
+ .update(copilotChats)
+ .set({
+ updatedAt: new Date(),
+ conversationId: responseId,
+ })
+ .where(eq(copilotChats.id, actualChatId!))
- logger.info(`[${tracker.requestId}] Updated chat ${actualChatId} with new messages`, {
- messageCount: updatedMessages.length,
- savedUserMessage: true,
- savedAssistantMessage: assistantContent.trim().length > 0,
- updatedConversationId: responseId || null,
- })
+ logger.info(
+ `[${tracker.requestId}] Updated conversationId for chat ${actualChatId}`,
+ {
+ updatedConversationId: responseId,
+ }
+ )
+ }
}
} catch (error) {
logger.error(`[${tracker.requestId}] Error processing stream:`, error)
diff --git a/apps/sim/app/api/copilot/chat/update-messages/route.ts b/apps/sim/app/api/copilot/chat/update-messages/route.ts
index adb040bcce..217ba0b058 100644
--- a/apps/sim/app/api/copilot/chat/update-messages/route.ts
+++ b/apps/sim/app/api/copilot/chat/update-messages/route.ts
@@ -77,6 +77,18 @@ export async function POST(req: NextRequest) {
const { chatId, messages, planArtifact, config } = UpdateMessagesSchema.parse(body)
+ // Debug: Log what we're about to save
+ const lastMsgParsed = messages[messages.length - 1]
+ if (lastMsgParsed?.role === 'assistant') {
+ logger.info(`[${tracker.requestId}] Parsed messages to save`, {
+ messageCount: messages.length,
+ lastMsgId: lastMsgParsed.id,
+ lastMsgContentLength: lastMsgParsed.content?.length || 0,
+ lastMsgContentBlockCount: lastMsgParsed.contentBlocks?.length || 0,
+ lastMsgContentBlockTypes: lastMsgParsed.contentBlocks?.map((b: any) => b?.type) || [],
+ })
+ }
+
// Verify that the chat belongs to the user
const [chat] = await db
.select()
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls/diff-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls/diff-controls.tsx
index 6d1ebac811..c34b7f73ed 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls/diff-controls.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls/diff-controls.tsx
@@ -303,8 +303,8 @@ export const DiffControls = memo(function DiffControls() {
!isResizing && 'transition-[bottom,right] duration-100 ease-out'
)}
style={{
- bottom: 'calc(var(--terminal-height) + 8px)',
- right: 'calc(var(--panel-width) + 8px)',
+ bottom: 'calc(var(--terminal-height) + 16px)',
+ right: 'calc(var(--panel-width) + 16px)',
}}
>
= memo(
{/* Content blocks in chronological order */}
{memoizedContentBlocks}
- {/* Show streaming indicator if streaming but no text content yet after tool calls */}
- {isStreaming &&
- !message.content &&
- message.contentBlocks?.every((block) => block.type === 'tool_call') && (
-
- )}
-
- {/* Streaming indicator when no content yet */}
- {!cleanTextContent && !message.contentBlocks?.length && isStreaming && (
-
- )}
+ {/* Always show streaming indicator at the end while streaming */}
+ {isStreaming &&
}
{message.errorType === 'usage_limit' && (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx
index 72dc6d6bcb..4f921c898e 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx
@@ -3,7 +3,8 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import { ChevronUp, LayoutList } from 'lucide-react'
-import { Button, Code } from '@/components/emcn'
+import Editor from 'react-simple-code-editor'
+import { Button, Code, getCodeEditorProps, highlight, languages } from '@/components/emcn'
import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool'
import { getClientTool } from '@/lib/copilot/tools/client/manager'
import { getRegisteredTools } from '@/lib/copilot/tools/client/registry'
@@ -413,6 +414,8 @@ const ACTION_VERBS = [
'Listed',
'Editing',
'Edited',
+ 'Executing',
+ 'Executed',
'Running',
'Ran',
'Designing',
@@ -751,36 +754,70 @@ function SubAgentToolCall({ toolCall: toolCallProp }: { toolCall: CopilotToolCal
const safeInputs = inputs && typeof inputs === 'object' ? inputs : {}
const inputEntries = Object.entries(safeInputs)
if (inputEntries.length === 0) return null
+
+ /**
+ * Format a value for display - handles objects, arrays, and primitives
+ */
+ const formatValue = (value: unknown): string => {
+ if (value === null || value === undefined) return '-'
+ if (typeof value === 'string') return value || '-'
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
+ try {
+ return JSON.stringify(value, null, 2)
+ } catch {
+ return String(value)
+ }
+ }
+
+ /**
+ * Check if a value is a complex type (object or array)
+ */
+ const isComplex = (value: unknown): boolean => {
+ return typeof value === 'object' && value !== null
+ }
+
return (
-
-
-
-
-
- Input
-
-
- Value
-
-
-
-
- {inputEntries.map(([key, value]) => (
-
-
-
- {key}
-
-
-
-
- {String(value)}
+
+ {/* Header */}
+
+ Input
+
+ {inputEntries.length}
+
+
+ {/* Input entries */}
+
+ {inputEntries.map(([key, value], index) => {
+ const formattedValue = formatValue(value)
+ const needsCodeViewer = isComplex(value)
+
+ return (
+
0 && 'border-[var(--border-1)] border-t'
+ )}
+ >
+ {/* Input key */}
+ {key}
+ {/* Value display */}
+ {needsCodeViewer ? (
+
+ ) : (
+
+ {formattedValue}
-
-
- ))}
-
-
+ )}
+
+ )
+ })}
+
)
}
@@ -2290,74 +2327,136 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
const safeInputs = inputs && typeof inputs === 'object' ? inputs : {}
const inputEntries = Object.entries(safeInputs)
- // Don't show the table if there are no inputs
+ // Don't show the section if there are no inputs
if (inputEntries.length === 0) {
return null
}
+ /**
+ * Format a value for display - handles objects, arrays, and primitives
+ */
+ const formatValueForDisplay = (value: unknown): string => {
+ if (value === null || value === undefined) return ''
+ if (typeof value === 'string') return value
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
+ // For objects and arrays, use JSON.stringify with formatting
+ try {
+ return JSON.stringify(value, null, 2)
+ } catch {
+ return String(value)
+ }
+ }
+
+ /**
+ * Parse a string value back to its original type if possible
+ */
+ const parseInputValue = (value: string, originalValue: unknown): unknown => {
+ // If original was a primitive, keep as string
+ if (typeof originalValue !== 'object' || originalValue === null) {
+ return value
+ }
+ // Try to parse as JSON for objects/arrays
+ try {
+ return JSON.parse(value)
+ } catch {
+ return value
+ }
+ }
+
+ /**
+ * Check if a value is a complex type (object or array)
+ */
+ const isComplexValue = (value: unknown): boolean => {
+ return typeof value === 'object' && value !== null
+ }
+
return (
-
-
-
-
-
- Input
-
-
- Value
-
-
-
-
- {inputEntries.map(([key, value]) => (
-
+ {/* Header */}
+
+ Edit Input
+
+ {inputEntries.length}
+
+
+ {/* Input entries */}
+
+ {inputEntries.map(([key, value], index) => {
+ const isComplex = isComplexValue(value)
+ const displayValue = formatValueForDisplay(value)
+
+ return (
+
0 && 'border-[var(--border-1)] border-t'
+ )}
>
-
-
-
- {key}
-
-
-
-
-
- {
- const newInputs = { ...safeInputs, [key]: e.target.value }
-
- // Determine how to update based on original structure
- if (isNestedInWorkflowInput) {
- // Update workflow_input
- setEditedParams({ ...editedParams, workflow_input: newInputs })
- } else if (typeof editedParams.input === 'string') {
- // Input was a JSON string, serialize back
- setEditedParams({ ...editedParams, input: JSON.stringify(newInputs) })
- } else if (editedParams.input && typeof editedParams.input === 'object') {
- // Input is an object
- setEditedParams({ ...editedParams, input: newInputs })
- } else if (
- editedParams.inputs &&
- typeof editedParams.inputs === 'object'
- ) {
- // Inputs is an object
- setEditedParams({ ...editedParams, inputs: newInputs })
- } else {
- // Flat structure - update at base level
- setEditedParams({ ...editedParams, [key]: e.target.value })
- }
- }}
- className='w-full bg-transparent font-mono text-[var(--text-muted)] text-xs outline-none focus:text-[var(--text-primary)]'
- />
-
-
-
- ))}
-
-
+ {/* Input key */}
+
{key}
+ {/* Value editor */}
+ {isComplex ? (
+
+
+ {
+ const parsedValue = parseInputValue(newCode, value)
+ const newInputs = { ...safeInputs, [key]: parsedValue }
+
+ if (isNestedInWorkflowInput) {
+ setEditedParams({ ...editedParams, workflow_input: newInputs })
+ } else if (typeof editedParams.input === 'string') {
+ setEditedParams({ ...editedParams, input: JSON.stringify(newInputs) })
+ } else if (
+ editedParams.input &&
+ typeof editedParams.input === 'object'
+ ) {
+ setEditedParams({ ...editedParams, input: newInputs })
+ } else if (
+ editedParams.inputs &&
+ typeof editedParams.inputs === 'object'
+ ) {
+ setEditedParams({ ...editedParams, inputs: newInputs })
+ } else {
+ setEditedParams({ ...editedParams, [key]: parsedValue })
+ }
+ }}
+ highlight={(code) => highlight(code, languages.json, 'json')}
+ {...getCodeEditorProps()}
+ className={clsx(getCodeEditorProps().className, 'min-h-[40px]')}
+ style={{ minHeight: '40px' }}
+ />
+
+
+ ) : (
+
{
+ const parsedValue = parseInputValue(e.target.value, value)
+ const newInputs = { ...safeInputs, [key]: parsedValue }
+
+ if (isNestedInWorkflowInput) {
+ setEditedParams({ ...editedParams, workflow_input: newInputs })
+ } else if (typeof editedParams.input === 'string') {
+ setEditedParams({ ...editedParams, input: JSON.stringify(newInputs) })
+ } else if (editedParams.input && typeof editedParams.input === 'object') {
+ setEditedParams({ ...editedParams, input: newInputs })
+ } else if (editedParams.inputs && typeof editedParams.inputs === 'object') {
+ setEditedParams({ ...editedParams, inputs: newInputs })
+ } else {
+ setEditedParams({ ...editedParams, [key]: parsedValue })
+ }
+ }}
+ className='w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-1)] px-[8px] py-[6px] font-medium font-mono text-[13px] text-[var(--text-primary)] outline-none transition-colors placeholder:text-[var(--text-muted)] focus:outline-none'
+ />
+ )}
+
+ )
+ })}
+
)
}
@@ -2443,8 +2542,8 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
{code && (
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 550a5d9a54..03053ccf3d 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
@@ -124,8 +124,10 @@ export const Copilot = forwardRef(({ panelWidth }, ref
isSendingMessage,
})
- // Handle scroll management
- const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage)
+ // Handle scroll management (80px stickiness for copilot)
+ const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage, {
+ stickinessThreshold: 80,
+ })
// Handle chat history grouping
const { groupedChats, handleHistoryDropdownOpen: handleHistoryDropdownOpenHook } = useChatHistory(
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 e8299998c7..5959368e3a 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
@@ -12,6 +12,12 @@ interface UseScrollManagementOptions {
* - `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.
+ * Lower values = less sticky (user can scroll away easier).
+ * Default is 100px.
+ */
+ stickinessThreshold?: number
}
/**
@@ -34,6 +40,7 @@ export function useScrollManagement(
const programmaticScrollInProgressRef = useRef(false)
const lastScrollTopRef = useRef(0)
const scrollBehavior: 'auto' | 'smooth' = options?.behavior ?? 'smooth'
+ const stickinessThreshold = options?.stickinessThreshold ?? 100
/**
* Scrolls the container to the bottom with smooth animation
@@ -74,7 +81,7 @@ export function useScrollManagement(
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
- const nearBottom = distanceFromBottom <= 100
+ const nearBottom = distanceFromBottom <= stickinessThreshold
setIsNearBottom(nearBottom)
if (isSendingMessage) {
@@ -95,7 +102,7 @@ export function useScrollManagement(
// Track last scrollTop for direction detection
lastScrollTopRef.current = scrollTop
- }, [getScrollContainer, isSendingMessage, userHasScrolledDuringStream])
+ }, [getScrollContainer, isSendingMessage, userHasScrolledDuringStream, stickinessThreshold])
// Attach scroll listener
useEffect(() => {
@@ -174,14 +181,20 @@ export function useScrollManagement(
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
- const nearBottom = distanceFromBottom <= 120
+ const nearBottom = distanceFromBottom <= stickinessThreshold
if (nearBottom) {
scrollToBottom()
}
}, 100)
return () => window.clearInterval(intervalId)
- }, [isSendingMessage, userHasScrolledDuringStream, getScrollContainer, scrollToBottom])
+ }, [
+ isSendingMessage,
+ userHasScrolledDuringStream,
+ getScrollContainer,
+ scrollToBottom,
+ stickinessThreshold,
+ ])
return {
scrollAreaRef,
diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts
index 9c49d38041..d4e926c91e 100644
--- a/apps/sim/stores/panel/copilot/store.ts
+++ b/apps/sim/stores/panel/copilot/store.ts
@@ -271,11 +271,31 @@ function resolveToolDisplay(
if (cand?.text || cand?.icon) return { text: cand.text, icon: cand.icon }
}
} catch {}
- // Humanized fallback as last resort
+ // Humanized fallback as last resort - include state verb for proper verb-noun styling
try {
if (toolName) {
- const text = toolName.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
- return { text, icon: undefined as any }
+ const formattedName = toolName.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
+ // Add state verb prefix for verb-noun rendering in tool-call component
+ let stateVerb: string
+ switch (state) {
+ case ClientToolCallState.pending:
+ case ClientToolCallState.executing:
+ stateVerb = 'Executing'
+ break
+ case ClientToolCallState.success:
+ stateVerb = 'Executed'
+ break
+ case ClientToolCallState.error:
+ stateVerb = 'Failed'
+ break
+ case ClientToolCallState.rejected:
+ case ClientToolCallState.aborted:
+ stateVerb = 'Skipped'
+ break
+ default:
+ stateVerb = 'Executing'
+ }
+ return { text: `${stateVerb} ${formattedName}`, icon: undefined as any }
}
} catch {}
return undefined
@@ -572,8 +592,30 @@ function stripTodoTags(text: string): string {
*/
function deepClone(obj: T): T {
try {
- return JSON.parse(JSON.stringify(obj))
- } catch {
+ const json = JSON.stringify(obj)
+ if (!json || json === 'undefined') {
+ logger.warn('[deepClone] JSON.stringify returned empty for object', {
+ type: typeof obj,
+ isArray: Array.isArray(obj),
+ length: Array.isArray(obj) ? obj.length : undefined,
+ })
+ return obj
+ }
+ const parsed = JSON.parse(json)
+ // Verify the clone worked
+ if (Array.isArray(obj) && (!Array.isArray(parsed) || parsed.length !== obj.length)) {
+ logger.warn('[deepClone] Array clone mismatch', {
+ originalLength: obj.length,
+ clonedLength: Array.isArray(parsed) ? parsed.length : 'not array',
+ })
+ }
+ return parsed
+ } catch (err) {
+ logger.error('[deepClone] Failed to clone object', {
+ error: String(err),
+ type: typeof obj,
+ isArray: Array.isArray(obj),
+ })
return obj
}
}
@@ -587,11 +629,18 @@ function serializeMessagesForDB(messages: CopilotMessage[]): any[] {
const result = messages
.map((msg) => {
// Deep clone the entire message to ensure all nested data is serializable
+ // Ensure timestamp is always a string (Zod schema requires it)
+ let timestamp: string = msg.timestamp
+ if (typeof timestamp !== 'string') {
+ const ts = timestamp as any
+ timestamp = ts instanceof Date ? ts.toISOString() : new Date().toISOString()
+ }
+
const serialized: any = {
id: msg.id,
role: msg.role,
content: msg.content || '',
- timestamp: msg.timestamp,
+ timestamp,
}
// Deep clone contentBlocks (the main rendering data)
@@ -3151,7 +3200,7 @@ export const useCopilotStore = create()(
model: selectedModel,
}
- await fetch('/api/copilot/chat/update-messages', {
+ const saveResponse = await fetch('/api/copilot/chat/update-messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -3162,6 +3211,18 @@ export const useCopilotStore = create()(
}),
})
+ if (!saveResponse.ok) {
+ const errorText = await saveResponse.text().catch(() => '')
+ logger.error('[Stream Done] Failed to save messages to DB', {
+ status: saveResponse.status,
+ error: errorText,
+ })
+ } else {
+ logger.info('[Stream Done] Successfully saved messages to DB', {
+ messageCount: dbMessages.length,
+ })
+ }
+
// Update local chat object with plan artifact and config
set({
currentChat: {
@@ -3170,7 +3231,9 @@ export const useCopilotStore = create()(
config,
},
})
- } catch {}
+ } catch (err) {
+ logger.error('[Stream Done] Exception saving messages', { error: String(err) })
+ }
}
// Post copilot_stats record (input/output tokens can be null for now)