From 4431a1a48486e2049c36873fe9e606468e639f98 Mon Sep 17 00:00:00 2001 From: Martin Yankov <23098926+Lutherwaves@users.noreply.github.com> Date: Sat, 20 Dec 2025 04:59:08 +0200 Subject: [PATCH 01/39] fix(helm): add custom egress rules to realtime network policy (#2481) The realtime service network policy was missing the custom egress rules section that allows configuration of additional egress rules via values.yaml. This caused the realtime pods to be unable to connect to external databases (e.g., PostgreSQL on port 5432) when using external database configurations. The app network policy already had this section, but the realtime network policy was missing it, creating an inconsistency and preventing the realtime service from accessing external databases configured via networkPolicy.egress values. This fix adds the same custom egress rules template section to the realtime network policy, matching the app network policy behavior and allowing users to configure database connectivity via values.yaml. --- helm/sim/templates/networkpolicy.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/helm/sim/templates/networkpolicy.yaml b/helm/sim/templates/networkpolicy.yaml index deac5a5dba..7ef8697417 100644 --- a/helm/sim/templates/networkpolicy.yaml +++ b/helm/sim/templates/networkpolicy.yaml @@ -141,6 +141,10 @@ spec: ports: - protocol: TCP port: 443 + # Allow custom egress rules + {{- with .Values.networkPolicy.egress }} + {{- toYaml . | nindent 2 }} + {{- end }} {{- end }} {{- if .Values.postgresql.enabled }} From df80309c3b427a61caa4e4e1de794fab3ebf829f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 5 Jan 2026 18:32:35 -0800 Subject: [PATCH 02/39] Add subagents --- .../components/thinking-block.tsx | 202 ++++----- .../copilot-message/copilot-message.tsx | 13 +- .../components/tool-call/tool-call.tsx | 261 ++++++++++- .../lib/copilot/tools/client/other/debug.ts | 49 +++ apps/sim/stores/panel/copilot/store.ts | 405 ++++++++++++++++++ apps/sim/stores/panel/copilot/types.ts | 18 + 6 files changed, 820 insertions(+), 128 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/client/other/debug.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx index a647c50f03..00630dd36f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx @@ -5,72 +5,19 @@ import clsx from 'clsx' import { ChevronUp } from 'lucide-react' /** - * Timer update interval in milliseconds + * Max height for thinking content before internal scrolling kicks in */ -const TIMER_UPDATE_INTERVAL = 100 +const THINKING_MAX_HEIGHT = 125 /** - * Milliseconds threshold for displaying as seconds + * Interval for auto-scroll during streaming (ms) */ -const SECONDS_THRESHOLD = 1000 +const SCROLL_INTERVAL = 100 /** - * Props for the ShimmerOverlayText component - */ -interface ShimmerOverlayTextProps { - /** Label text to display */ - label: string - /** Value text to display */ - value: string - /** Whether the shimmer animation is active */ - active?: boolean -} - -/** - * ShimmerOverlayText component for thinking block - * Applies shimmer effect to the "Thought for X.Xs" text during streaming - * - * @param props - Component props - * @returns Text with optional shimmer overlay effect + * Timer update interval in milliseconds */ -function ShimmerOverlayText({ label, value, active = false }: ShimmerOverlayTextProps) { - return ( - - {label} - {value} - {active ? ( - - ) : null} - - - ) -} +const TIMER_UPDATE_INTERVAL = 100 /** * Props for the ThinkingBlock component @@ -80,16 +27,15 @@ interface ThinkingBlockProps { content: string /** Whether the block is currently streaming */ isStreaming?: boolean - /** Persisted duration from content block */ - duration?: number - /** Persisted start time from content block */ - startTime?: number + /** Whether there are more content blocks after this one (e.g., tool calls) */ + hasFollowingContent?: boolean } /** * ThinkingBlock component displays AI reasoning/thinking process * Shows collapsible content with duration timer * Auto-expands during streaming and collapses when complete + * Auto-collapses when a tool call or other content comes in after it * * @param props - Component props * @returns Thinking block with expandable content and timer @@ -97,29 +43,21 @@ interface ThinkingBlockProps { export function ThinkingBlock({ content, isStreaming = false, - duration: persistedDuration, - startTime: persistedStartTime, + hasFollowingContent = false, }: ThinkingBlockProps) { const [isExpanded, setIsExpanded] = useState(false) - const [duration, setDuration] = useState(persistedDuration ?? 0) + const [duration, setDuration] = useState(0) const userCollapsedRef = useRef(false) - const startTimeRef = useRef(persistedStartTime ?? Date.now()) - - /** - * Updates start time reference when persisted start time changes - */ - useEffect(() => { - if (typeof persistedStartTime === 'number') { - startTimeRef.current = persistedStartTime - } - }, [persistedStartTime]) + const scrollContainerRef = useRef(null) + const startTimeRef = useRef(Date.now()) /** * Auto-expands block when streaming with content - * Auto-collapses when streaming ends + * Auto-collapses when streaming ends OR when following content arrives */ useEffect(() => { - if (!isStreaming) { + // Collapse if streaming ended or if there's following content (like a tool call) + if (!isStreaming || hasFollowingContent) { setIsExpanded(false) userCollapsedRef.current = false return @@ -128,42 +66,57 @@ export function ThinkingBlock({ if (!userCollapsedRef.current && content && content.trim().length > 0) { setIsExpanded(true) } - }, [isStreaming, content]) + }, [isStreaming, content, hasFollowingContent]) - /** - * Updates duration timer during streaming - * Uses persisted duration when available - */ + // Reset start time when streaming begins useEffect(() => { - if (typeof persistedDuration === 'number') { - setDuration(persistedDuration) - return + if (isStreaming && !hasFollowingContent) { + startTimeRef.current = Date.now() + setDuration(0) } + }, [isStreaming, hasFollowingContent]) - if (isStreaming) { - const interval = setInterval(() => { - setDuration(Date.now() - startTimeRef.current) - }, TIMER_UPDATE_INTERVAL) - return () => clearInterval(interval) - } + // Update duration timer during streaming (stop when following content arrives) + useEffect(() => { + // Stop timer if not streaming or if there's following content (thinking is done) + if (!isStreaming || hasFollowingContent) return + + const interval = setInterval(() => { + setDuration(Date.now() - startTimeRef.current) + }, TIMER_UPDATE_INTERVAL) - setDuration(Date.now() - startTimeRef.current) - }, [isStreaming, persistedDuration]) + return () => clearInterval(interval) + }, [isStreaming, hasFollowingContent]) + + // Auto-scroll to bottom during streaming using interval (same as copilot chat) + useEffect(() => { + if (!isStreaming || !isExpanded) return + + const intervalId = window.setInterval(() => { + const container = scrollContainerRef.current + if (!container) return + + container.scrollTo({ + top: container.scrollHeight, + behavior: 'smooth', + }) + }, SCROLL_INTERVAL) + + return () => window.clearInterval(intervalId) + }, [isStreaming, isExpanded]) /** - * Formats duration in milliseconds to human-readable format - * @param ms - Duration in milliseconds - * @returns Formatted string (e.g., "150ms" or "2.5s") + * Formats duration in milliseconds to seconds + * Always shows seconds, rounded to nearest whole second, minimum 1s */ const formatDuration = (ms: number) => { - if (ms < SECONDS_THRESHOLD) { - return `${ms}ms` - } - const seconds = (ms / SECONDS_THRESHOLD).toFixed(1) + const seconds = Math.max(1, Math.round(ms / 1000)) return `${seconds}s` } const hasContent = content && content.trim().length > 0 + const label = isStreaming ? 'Thinking' : 'Thought' + const durationText = ` for ${formatDuration(duration)}` return (
@@ -180,21 +133,54 @@ export function ThinkingBlock({ type='button' disabled={!hasContent} > - + + {label} + {durationText} + {isStreaming && ( + + )} + + {hasContent && (
) : null} + {/* Render subagent content (from debug tool or other subagents) */} + {toolCall.subAgentBlocks && toolCall.subAgentBlocks.length > 0 && ( + + )} ) } diff --git a/apps/sim/lib/copilot/tools/client/other/debug.ts b/apps/sim/lib/copilot/tools/client/other/debug.ts new file mode 100644 index 0000000000..ba6ee82e72 --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/other/debug.ts @@ -0,0 +1,49 @@ +import { Bug, Loader2, XCircle } from 'lucide-react' +import { + BaseClientTool, + type BaseClientToolMetadata, + ClientToolCallState, +} from '@/lib/copilot/tools/client/base-tool' + +interface DebugArgs { + error_description: string + context?: string +} + +/** + * Debug tool that spawns a subagent to diagnose workflow issues. + * This tool auto-executes and the actual work is done by the debug subagent. + * The subagent's output is streamed as nested content under this tool call. + */ +export class DebugClientTool extends BaseClientTool { + static readonly id = 'debug' + + constructor(toolCallId: string) { + super(toolCallId, DebugClientTool.id, DebugClientTool.metadata) + } + + static readonly metadata: BaseClientToolMetadata = { + displayNames: { + [ClientToolCallState.generating]: { text: 'Preparing debug session', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Debugging', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Debugging', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Debugged', icon: Bug }, + [ClientToolCallState.error]: { text: 'Failed to debug', icon: XCircle }, + [ClientToolCallState.rejected]: { text: 'Debug skipped', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Debug aborted', icon: XCircle }, + }, + } + + /** + * Execute the debug tool. + * This just marks the tool as executing - the actual debug work is done server-side + * by the debug subagent, and its output is streamed as subagent events. + */ + async execute(_args?: DebugArgs): Promise { + // Immediately transition to executing state - no user confirmation needed + this.setState(ClientToolCallState.executing) + // The tool result will come from the server via tool_result event + // when the debug subagent completes its work + } +} + diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index d00ca84c7a..acbea2820b 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -33,6 +33,7 @@ import { PlanClientTool } from '@/lib/copilot/tools/client/other/plan' import { RememberDebugClientTool } from '@/lib/copilot/tools/client/other/remember-debug' import { SearchDocumentationClientTool } from '@/lib/copilot/tools/client/other/search-documentation' import { SearchErrorsClientTool } from '@/lib/copilot/tools/client/other/search-errors' +import { DebugClientTool } from '@/lib/copilot/tools/client/other/debug' import { SearchOnlineClientTool } from '@/lib/copilot/tools/client/other/search-online' import { SearchPatternsClientTool } from '@/lib/copilot/tools/client/other/search-patterns' import { SleepClientTool } from '@/lib/copilot/tools/client/other/sleep' @@ -78,6 +79,7 @@ try { // Known class-based client tools: map tool name -> instantiator const CLIENT_TOOL_INSTANTIATORS: Record any> = { + debug: (id) => new DebugClientTool(id), run_workflow: (id) => new RunWorkflowClientTool(id), get_workflow_console: (id) => new GetWorkflowConsoleClientTool(id), get_blocks_and_tools: (id) => new GetBlocksAndToolsClientTool(id), @@ -120,6 +122,7 @@ const CLIENT_TOOL_INSTANTIATORS: Record any> = { // Read-only static metadata for class-based tools (no instances) export const CLASS_TOOL_METADATA: Record = { + debug: (DebugClientTool as any)?.metadata, run_workflow: (RunWorkflowClientTool as any)?.metadata, get_workflow_console: (GetWorkflowConsoleClientTool as any)?.metadata, get_blocks_and_tools: (GetBlocksAndToolsClientTool as any)?.metadata, @@ -650,6 +653,14 @@ interface StreamingContext { newChatId?: string doneEventCount: number streamComplete?: boolean + /** Track active subagent sessions by parent tool call ID */ + subAgentParentToolCallId?: string + /** Track subagent content per parent tool call */ + subAgentContent: Record + /** Track subagent tool calls per parent tool call */ + subAgentToolCalls: Record + /** Track subagent streaming blocks per parent tool call */ + subAgentBlocks: Record } type SSEHandler = ( @@ -1474,6 +1485,304 @@ const sseHandlers: Record = { default: () => {}, } +/** + * Helper to update a tool call with subagent data in both toolCallsById and contentBlocks + */ +function updateToolCallWithSubAgentData( + context: StreamingContext, + get: () => CopilotStore, + set: any, + parentToolCallId: string +) { + const { toolCallsById } = get() + const parentToolCall = toolCallsById[parentToolCallId] + if (!parentToolCall) { + logger.warn('[SubAgent] updateToolCallWithSubAgentData: parent tool call not found', { + parentToolCallId, + availableToolCallIds: Object.keys(toolCallsById), + }) + return + } + + // Prepare subagent blocks array for ordered display + const blocks = context.subAgentBlocks[parentToolCallId] || [] + + const updatedToolCall: CopilotToolCall = { + ...parentToolCall, + subAgentContent: context.subAgentContent[parentToolCallId] || '', + subAgentToolCalls: context.subAgentToolCalls[parentToolCallId] || [], + subAgentBlocks: blocks, + subAgentStreaming: true, + } + + logger.info('[SubAgent] Updating tool call with subagent data', { + parentToolCallId, + parentToolName: parentToolCall.name, + subAgentContentLength: updatedToolCall.subAgentContent?.length, + subAgentBlocksCount: updatedToolCall.subAgentBlocks?.length, + subAgentToolCallsCount: updatedToolCall.subAgentToolCalls?.length, + }) + + // Update in toolCallsById + const updatedMap = { ...toolCallsById, [parentToolCallId]: updatedToolCall } + set({ toolCallsById: updatedMap }) + + // Update in contentBlocks + let foundInContentBlocks = false + for (let i = 0; i < context.contentBlocks.length; i++) { + const b = context.contentBlocks[i] as any + if (b.type === 'tool_call' && b.toolCall?.id === parentToolCallId) { + context.contentBlocks[i] = { ...b, toolCall: updatedToolCall } + foundInContentBlocks = true + break + } + } + + if (!foundInContentBlocks) { + logger.warn('[SubAgent] Parent tool call not found in contentBlocks', { + parentToolCallId, + contentBlocksCount: context.contentBlocks.length, + toolCallBlockIds: context.contentBlocks + .filter((b: any) => b.type === 'tool_call') + .map((b: any) => b.toolCall?.id), + }) + } + + updateStreamingMessage(set, context) +} + +/** + * SSE handlers for subagent events (events with subagent field set) + * These handle content and tool calls from subagents like debug + */ +const subAgentSSEHandlers: Record = { + // Handle subagent response start (ignore - just a marker) + start: () => { + // Subagent start event - no action needed, parent is already tracked from subagent_start + }, + + // Handle subagent text content (reasoning/thinking) + content: (data, context, get, set) => { + const parentToolCallId = context.subAgentParentToolCallId + logger.info('[SubAgent] content event', { + parentToolCallId, + hasData: !!data.data, + dataPreview: typeof data.data === 'string' ? data.data.substring(0, 50) : null, + }) + if (!parentToolCallId || !data.data) { + logger.warn('[SubAgent] content missing parentToolCallId or data', { + parentToolCallId, + hasData: !!data.data, + }) + return + } + + // Initialize if needed + if (!context.subAgentContent[parentToolCallId]) { + context.subAgentContent[parentToolCallId] = '' + } + if (!context.subAgentBlocks[parentToolCallId]) { + context.subAgentBlocks[parentToolCallId] = [] + } + + // Append content + context.subAgentContent[parentToolCallId] += data.data + + // Update or create the last text block in subAgentBlocks + const blocks = context.subAgentBlocks[parentToolCallId] + const lastBlock = blocks[blocks.length - 1] + if (lastBlock && lastBlock.type === 'subagent_text') { + lastBlock.content = (lastBlock.content || '') + data.data + } else { + blocks.push({ + type: 'subagent_text', + content: data.data, + timestamp: Date.now(), + }) + } + + updateToolCallWithSubAgentData(context, get, set, parentToolCallId) + }, + + // Handle subagent reasoning (same as content for subagent display purposes) + reasoning: (data, context, get, set) => { + const parentToolCallId = context.subAgentParentToolCallId + const phase = data?.phase || data?.data?.phase + if (!parentToolCallId) return + + // Initialize if needed + if (!context.subAgentContent[parentToolCallId]) { + context.subAgentContent[parentToolCallId] = '' + } + if (!context.subAgentBlocks[parentToolCallId]) { + context.subAgentBlocks[parentToolCallId] = [] + } + + // For reasoning, we just append the content (treating start/end as markers) + if (phase === 'start' || phase === 'end') return + + const chunk = typeof data?.data === 'string' ? data.data : data?.content || '' + if (!chunk) return + + context.subAgentContent[parentToolCallId] += chunk + + // Update or create the last text block in subAgentBlocks + const blocks = context.subAgentBlocks[parentToolCallId] + const lastBlock = blocks[blocks.length - 1] + if (lastBlock && lastBlock.type === 'subagent_text') { + lastBlock.content = (lastBlock.content || '') + chunk + } else { + blocks.push({ + type: 'subagent_text', + content: chunk, + timestamp: Date.now(), + }) + } + + updateToolCallWithSubAgentData(context, get, set, parentToolCallId) + }, + + // Handle subagent tool_generating (tool is being generated) + tool_generating: () => { + // Tool generating event - no action needed, we'll handle the actual tool_call + }, + + // Handle subagent tool calls - also execute client tools + tool_call: async (data, context, get, set) => { + const parentToolCallId = context.subAgentParentToolCallId + if (!parentToolCallId) return + + const toolData = data?.data || {} + const id: string | undefined = toolData.id || data?.toolCallId + const name: string | undefined = toolData.name || data?.toolName + if (!id || !name) return + + const args = toolData.arguments + + // Initialize if needed + if (!context.subAgentToolCalls[parentToolCallId]) { + context.subAgentToolCalls[parentToolCallId] = [] + } + if (!context.subAgentBlocks[parentToolCallId]) { + context.subAgentBlocks[parentToolCallId] = [] + } + + // Ensure client tool instance is registered (for execution) + ensureClientToolInstance(name, id) + + // Create or update the subagent tool call + const existingIndex = context.subAgentToolCalls[parentToolCallId].findIndex((tc) => tc.id === id) + const subAgentToolCall: CopilotToolCall = { + id, + name, + state: ClientToolCallState.pending, + ...(args ? { params: args } : {}), + display: resolveToolDisplay(name, ClientToolCallState.pending, id, args), + } + + if (existingIndex >= 0) { + context.subAgentToolCalls[parentToolCallId][existingIndex] = subAgentToolCall + } else { + context.subAgentToolCalls[parentToolCallId].push(subAgentToolCall) + + // Also add to ordered blocks + context.subAgentBlocks[parentToolCallId].push({ + type: 'subagent_tool_call', + toolCall: subAgentToolCall, + timestamp: Date.now(), + }) + } + + // Also add to main toolCallsById for proper tool execution + const { toolCallsById } = get() + const updated = { ...toolCallsById, [id]: subAgentToolCall } + set({ toolCallsById: updated }) + + updateToolCallWithSubAgentData(context, get, set, parentToolCallId) + + // Execute client tools (same logic as main tool_call handler) + try { + const def = getTool(name) + if (def) { + const hasInterrupt = + typeof def.hasInterrupt === 'function' ? !!def.hasInterrupt(args || {}) : !!def.hasInterrupt + if (!hasInterrupt) { + // Auto-execute tools without interrupts + const ctx = createExecutionContext({ toolCallId: id, toolName: name }) + try { + await def.execute(args || {}, ctx) + } catch (execErr: any) { + logger.error('[SubAgent] Tool execution failed', { id, name, error: execErr?.message }) + } + } + } else { + // Fallback to class-based tools + const instance = getClientTool(id) + if (instance) { + const hasInterruptDisplays = !!instance.getInterruptDisplays?.() + if (!hasInterruptDisplays) { + try { + await instance.execute(args) + } catch (execErr: any) { + logger.error('[SubAgent] Class tool execution failed', { id, name, error: execErr?.message }) + } + } + } + } + } catch (e: any) { + logger.error('[SubAgent] Tool registry/execution error', { id, name, error: e?.message }) + } + }, + + // Handle subagent tool results + tool_result: (data, context, get, set) => { + const parentToolCallId = context.subAgentParentToolCallId + if (!parentToolCallId) return + + const toolCallId: string | undefined = data?.toolCallId || data?.data?.id + const success: boolean | undefined = data?.success !== false // Default to true if not specified + if (!toolCallId) return + + // Initialize if needed + if (!context.subAgentToolCalls[parentToolCallId]) return + if (!context.subAgentBlocks[parentToolCallId]) return + + // Update the subagent tool call state + const targetState = success ? ClientToolCallState.success : ClientToolCallState.error + const existingIndex = context.subAgentToolCalls[parentToolCallId].findIndex( + (tc) => tc.id === toolCallId + ) + + if (existingIndex >= 0) { + const existing = context.subAgentToolCalls[parentToolCallId][existingIndex] + context.subAgentToolCalls[parentToolCallId][existingIndex] = { + ...existing, + state: targetState, + display: resolveToolDisplay(existing.name, targetState, toolCallId, existing.params), + } + + // Also update in ordered blocks + for (const block of context.subAgentBlocks[parentToolCallId]) { + if (block.type === 'subagent_tool_call' && block.toolCall?.id === toolCallId) { + block.toolCall = context.subAgentToolCalls[parentToolCallId][existingIndex] + break + } + } + } + + updateToolCallWithSubAgentData(context, get, set, parentToolCallId) + }, + + // Handle subagent stream done - just update the streaming state + done: (data, context, get, set) => { + const parentToolCallId = context.subAgentParentToolCallId + if (!parentToolCallId) return + + // Update the tool call with final content but keep streaming true until subagent_end + updateToolCallWithSubAgentData(context, get, set, parentToolCallId) + }, +} + // Debounced UI update queue for smoother streaming const streamingUpdateQueue = new Map() let streamingUpdateRAF: number | null = null @@ -2540,6 +2849,9 @@ export const useCopilotStore = create()( designWorkflowContent: '', pendingContent: '', doneEventCount: 0, + subAgentContent: {}, + subAgentToolCalls: {}, + subAgentBlocks: {}, } if (isContinuation) { @@ -2563,6 +2875,99 @@ export const useCopilotStore = create()( const { abortController } = get() if (abortController?.signal.aborted) break + // Log SSE events for debugging + logger.info('[SSE] Received event', { + type: data.type, + hasSubAgent: !!data.subagent, + subagent: data.subagent, + dataPreview: + typeof data.data === 'string' + ? data.data.substring(0, 100) + : JSON.stringify(data.data)?.substring(0, 100), + }) + + // Handle subagent_start to track parent tool call + if (data.type === 'subagent_start') { + const toolCallId = data.data?.tool_call_id + if (toolCallId) { + context.subAgentParentToolCallId = toolCallId + // Mark the parent tool call as streaming + const { toolCallsById } = get() + const parentToolCall = toolCallsById[toolCallId] + if (parentToolCall) { + const updatedToolCall: CopilotToolCall = { + ...parentToolCall, + subAgentStreaming: true, + } + const updatedMap = { ...toolCallsById, [toolCallId]: updatedToolCall } + set({ toolCallsById: updatedMap }) + } + logger.info('[SSE] Subagent session started', { + subagent: data.subagent, + parentToolCallId: toolCallId, + }) + } + continue + } + + // Handle subagent_end to finalize subagent content + if (data.type === 'subagent_end') { + const parentToolCallId = context.subAgentParentToolCallId + if (parentToolCallId) { + // Mark subagent streaming as complete + const { toolCallsById } = get() + const parentToolCall = toolCallsById[parentToolCallId] + if (parentToolCall) { + const updatedToolCall: CopilotToolCall = { + ...parentToolCall, + subAgentContent: context.subAgentContent[parentToolCallId] || '', + subAgentToolCalls: context.subAgentToolCalls[parentToolCallId] || [], + subAgentBlocks: context.subAgentBlocks[parentToolCallId] || [], + subAgentStreaming: false, // Done streaming + } + const updatedMap = { ...toolCallsById, [parentToolCallId]: updatedToolCall } + set({ toolCallsById: updatedMap }) + logger.info('[SSE] Subagent session ended', { + subagent: data.subagent, + parentToolCallId, + contentLength: context.subAgentContent[parentToolCallId]?.length || 0, + toolCallCount: context.subAgentToolCalls[parentToolCallId]?.length || 0, + }) + } + } + context.subAgentParentToolCallId = undefined + continue + } + + // Check if this is a subagent event (has subagent field) + if (data.subagent) { + const parentToolCallId = context.subAgentParentToolCallId + if (!parentToolCallId) { + logger.warn('[SSE] Subagent event without parent tool call ID', { + type: data.type, + subagent: data.subagent, + }) + continue + } + + logger.info('[SSE] Processing subagent event', { + type: data.type, + subagent: data.subagent, + parentToolCallId, + hasHandler: !!subAgentSSEHandlers[data.type], + }) + + const subAgentHandler = subAgentSSEHandlers[data.type] + if (subAgentHandler) { + await subAgentHandler(data, context, get, set) + } else { + logger.warn('[SSE] No handler for subagent event type', { type: data.type }) + } + // Skip regular handlers for subagent events + if (context.streamComplete) break + continue + } + const handler = sseHandlers[data.type] || sseHandlers.default await handler(data, context, get, set) if (context.streamComplete) break diff --git a/apps/sim/stores/panel/copilot/types.ts b/apps/sim/stores/panel/copilot/types.ts index f021aa7173..5e9a987a47 100644 --- a/apps/sim/stores/panel/copilot/types.ts +++ b/apps/sim/stores/panel/copilot/types.ts @@ -2,12 +2,30 @@ import type { ClientToolCallState, ClientToolDisplay } from '@/lib/copilot/tools export type ToolState = ClientToolCallState +/** + * Subagent content block for nested thinking/reasoning inside a tool call + */ +export interface SubAgentContentBlock { + type: 'subagent_text' | 'subagent_tool_call' + content?: string + toolCall?: CopilotToolCall + timestamp: number +} + export interface CopilotToolCall { id: string name: string state: ClientToolCallState params?: Record display?: ClientToolDisplay + /** Content streamed from a subagent (e.g., debug agent) */ + subAgentContent?: string + /** Tool calls made by the subagent */ + subAgentToolCalls?: CopilotToolCall[] + /** Structured content blocks for subagent (thinking + tool calls in order) */ + subAgentBlocks?: SubAgentContentBlock[] + /** Whether subagent is currently streaming */ + subAgentStreaming?: boolean } export interface MessageFileAttachment { From f6cd0cbc5569628f6de08be926c0d94ca9e40f69 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 5 Jan 2026 19:48:49 -0800 Subject: [PATCH 03/39] Edit, plan, debug subagents --- .../components/tool-call/tool-call.tsx | 29 +++++++- .../copilot/tools/client/other/apply-edit.ts | 48 +++++++++++++ .../lib/copilot/tools/client/other/plan.ts | 68 +++++++------------ apps/sim/stores/panel/copilot/store.ts | 3 + 4 files changed, 100 insertions(+), 48 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/client/other/apply-edit.ts 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 979f24fbc6..446a173629 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 @@ -298,6 +298,22 @@ const SUBAGENT_MAX_HEIGHT = 125 */ const SUBAGENT_SCROLL_INTERVAL = 100 +/** + * Get display labels for subagent tools + */ +function getSubagentLabels(toolName: string, isStreaming: boolean): string { + switch (toolName) { + case 'debug': + return isStreaming ? 'Debugging' : 'Debugged' + case 'apply_edit': + return isStreaming ? 'Applying edit' : 'Applied edit' + case 'plan': + return isStreaming ? 'Planning' : 'Planned' + default: + return isStreaming ? 'Processing' : 'Processed' + } +} + /** * SubAgentContent renders the streamed content and tool calls from a subagent * with thinking-style styling (same as ThinkingBlock). @@ -306,9 +322,11 @@ const SUBAGENT_SCROLL_INTERVAL = 100 function SubAgentContent({ blocks, isStreaming = false, + toolName = 'debug', }: { blocks?: SubAgentContentBlock[] isStreaming?: boolean + toolName?: string }) { const [isExpanded, setIsExpanded] = useState(false) const userCollapsedRef = useRef(false) @@ -347,7 +365,7 @@ function SubAgentContent({ if (!blocks || blocks.length === 0) return null const hasContent = blocks.length > 0 - const label = isStreaming ? 'Debugging' : 'Debugged' + const label = getSubagentLabels(toolName, isStreaming) return (
@@ -768,13 +786,15 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: // Skip rendering some internal tools if (toolCall.name === 'checkoff_todo' || toolCall.name === 'mark_todo_in_progress') return null - // Special rendering for debug tool with subagent content - only show the collapsible SubAgentContent - if (toolCall.name === 'debug' && toolCall.subAgentBlocks && toolCall.subAgentBlocks.length > 0) { + // Special rendering for subagent tools (debug, apply_edit, plan) - only show the collapsible SubAgentContent + const isSubagentTool = toolCall.name === 'debug' || toolCall.name === 'apply_edit' || toolCall.name === 'plan' + if (isSubagentTool && toolCall.subAgentBlocks && toolCall.subAgentBlocks.length > 0) { return (
) @@ -1209,6 +1229,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: )}
@@ -1271,6 +1292,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: )} @@ -1380,6 +1402,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: )} diff --git a/apps/sim/lib/copilot/tools/client/other/apply-edit.ts b/apps/sim/lib/copilot/tools/client/other/apply-edit.ts new file mode 100644 index 0000000000..e02cd9cdfc --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/other/apply-edit.ts @@ -0,0 +1,48 @@ +import { Loader2, Pencil, XCircle } from 'lucide-react' +import { + BaseClientTool, + type BaseClientToolMetadata, + ClientToolCallState, +} from '@/lib/copilot/tools/client/base-tool' + +interface ApplyEditArgs { + instruction: string +} + +/** + * Apply Edit tool that spawns a subagent to apply code/workflow edits. + * This tool auto-executes and the actual work is done by the apply_edit subagent. + * The subagent's output is streamed as nested content under this tool call. + */ +export class ApplyEditClientTool extends BaseClientTool { + static readonly id = 'apply_edit' + + constructor(toolCallId: string) { + super(toolCallId, ApplyEditClientTool.id, ApplyEditClientTool.metadata) + } + + static readonly metadata: BaseClientToolMetadata = { + displayNames: { + [ClientToolCallState.generating]: { text: 'Preparing edit', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Applying edit', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Applying edit', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Edit applied', icon: Pencil }, + [ClientToolCallState.error]: { text: 'Failed to apply edit', icon: XCircle }, + [ClientToolCallState.rejected]: { text: 'Edit skipped', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Edit aborted', icon: XCircle }, + }, + } + + /** + * Execute the apply_edit tool. + * This just marks the tool as executing - the actual edit work is done server-side + * by the apply_edit subagent, and its output is streamed as subagent events. + */ + async execute(_args?: ApplyEditArgs): Promise { + // Immediately transition to executing state - no user confirmation needed + this.setState(ClientToolCallState.executing) + // The tool result will come from the server via tool_result event + // when the apply_edit subagent completes its work + } +} + diff --git a/apps/sim/lib/copilot/tools/client/other/plan.ts b/apps/sim/lib/copilot/tools/client/other/plan.ts index ebd43a9ce4..ee182bd8f2 100644 --- a/apps/sim/lib/copilot/tools/client/other/plan.ts +++ b/apps/sim/lib/copilot/tools/client/other/plan.ts @@ -1,5 +1,4 @@ -import { createLogger } from '@sim/logger' -import { ListTodo, Loader2, X, XCircle } from 'lucide-react' +import { ListTodo, Loader2, XCircle } from 'lucide-react' import { BaseClientTool, type BaseClientToolMetadata, @@ -7,10 +6,14 @@ import { } from '@/lib/copilot/tools/client/base-tool' interface PlanArgs { - objective?: string - todoList?: Array<{ id?: string; content: string } | string> + request: string } +/** + * Plan tool that spawns a subagent to plan an approach. + * This tool auto-executes and the actual work is done by the plan subagent. + * The subagent's output is streamed as nested content under this tool call. + */ export class PlanClientTool extends BaseClientTool { static readonly id = 'plan' @@ -20,50 +23,25 @@ export class PlanClientTool extends BaseClientTool { static readonly metadata: BaseClientToolMetadata = { displayNames: { - [ClientToolCallState.generating]: { text: 'Planning', icon: Loader2 }, + [ClientToolCallState.generating]: { text: 'Preparing plan', icon: Loader2 }, [ClientToolCallState.pending]: { text: 'Planning', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Planning an approach', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Finished planning', icon: ListTodo }, - [ClientToolCallState.error]: { text: 'Failed to plan', icon: X }, - [ClientToolCallState.aborted]: { text: 'Aborted planning', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped planning approach', icon: XCircle }, + [ClientToolCallState.executing]: { text: 'Planning', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Planned', icon: ListTodo }, + [ClientToolCallState.error]: { text: 'Failed to plan', icon: XCircle }, + [ClientToolCallState.rejected]: { text: 'Plan skipped', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Plan aborted', icon: XCircle }, }, } - async execute(args?: PlanArgs): Promise { - const logger = createLogger('PlanClientTool') - try { - this.setState(ClientToolCallState.executing) - - // Update store todos from args if present (client-side only) - try { - const todoList = args?.todoList - if (Array.isArray(todoList)) { - const todos = todoList.map((item: any, index: number) => ({ - id: (item && (item.id || item.todoId)) || `todo-${index}`, - content: typeof item === 'string' ? item : item.content, - completed: false, - executing: false, - })) - const { useCopilotStore } = await import('@/stores/panel/copilot/store') - const store = useCopilotStore.getState() - if (store.setPlanTodos) { - store.setPlanTodos(todos) - useCopilotStore.setState({ showPlanTodos: true }) - } - } - } catch (e) { - logger.warn('Failed to update plan todos in store', { message: (e as any)?.message }) - } - - this.setState(ClientToolCallState.success) - // Echo args back so store/tooling can parse todoList if needed - await this.markToolComplete(200, 'Plan ready', args || {}) - this.setState(ClientToolCallState.success) - } catch (e: any) { - logger.error('execute failed', { message: e?.message }) - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, e?.message || 'Failed to plan') - } + /** + * Execute the plan tool. + * This just marks the tool as executing - the actual planning work is done server-side + * by the plan subagent, and its output is streamed as subagent events. + */ + async execute(_args?: PlanArgs): Promise { + // Immediately transition to executing state - no user confirmation needed + this.setState(ClientToolCallState.executing) + // The tool result will come from the server via tool_result event + // when the plan subagent completes its work } } diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index acbea2820b..6417f5b049 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -33,6 +33,7 @@ import { PlanClientTool } from '@/lib/copilot/tools/client/other/plan' import { RememberDebugClientTool } from '@/lib/copilot/tools/client/other/remember-debug' import { SearchDocumentationClientTool } from '@/lib/copilot/tools/client/other/search-documentation' import { SearchErrorsClientTool } from '@/lib/copilot/tools/client/other/search-errors' +import { ApplyEditClientTool } from '@/lib/copilot/tools/client/other/apply-edit' import { DebugClientTool } from '@/lib/copilot/tools/client/other/debug' import { SearchOnlineClientTool } from '@/lib/copilot/tools/client/other/search-online' import { SearchPatternsClientTool } from '@/lib/copilot/tools/client/other/search-patterns' @@ -79,6 +80,7 @@ try { // Known class-based client tools: map tool name -> instantiator const CLIENT_TOOL_INSTANTIATORS: Record any> = { + apply_edit: (id) => new ApplyEditClientTool(id), debug: (id) => new DebugClientTool(id), run_workflow: (id) => new RunWorkflowClientTool(id), get_workflow_console: (id) => new GetWorkflowConsoleClientTool(id), @@ -122,6 +124,7 @@ const CLIENT_TOOL_INSTANTIATORS: Record any> = { // Read-only static metadata for class-based tools (no instances) export const CLASS_TOOL_METADATA: Record = { + apply_edit: (ApplyEditClientTool as any)?.metadata, debug: (DebugClientTool as any)?.metadata, run_workflow: (RunWorkflowClientTool as any)?.metadata, get_workflow_console: (GetWorkflowConsoleClientTool as any)?.metadata, From 56366088d8435bad768be27a20e7641d6c2e7b07 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 5 Jan 2026 20:33:41 -0800 Subject: [PATCH 04/39] Tweaks --- apps/sim/stores/panel/copilot/store.ts | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index 6417f5b049..2677288cd7 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -1660,7 +1660,27 @@ const subAgentSSEHandlers: Record = { const name: string | undefined = toolData.name || data?.toolName if (!id || !name) return - const args = toolData.arguments + // Arguments can come in different locations depending on SSE format + // Check multiple possible locations + let args = toolData.arguments || toolData.input || data?.arguments || data?.input + + // If arguments is a string, try to parse it as JSON + if (typeof args === 'string') { + try { + args = JSON.parse(args) + } catch { + logger.warn('[SubAgent] Failed to parse arguments string', { args }) + } + } + + logger.info('[SubAgent] tool_call received', { + id, + name, + hasArgs: !!args, + argsKeys: args ? Object.keys(args) : [], + toolDataKeys: Object.keys(toolData), + dataKeys: Object.keys(data || {}), + }) // Initialize if needed if (!context.subAgentToolCalls[parentToolCallId]) { @@ -1713,7 +1733,7 @@ const subAgentSSEHandlers: Record = { // Auto-execute tools without interrupts const ctx = createExecutionContext({ toolCallId: id, toolName: name }) try { - await def.execute(args || {}, ctx) + await def.execute(ctx, args || {}) } catch (execErr: any) { logger.error('[SubAgent] Tool execution failed', { id, name, error: execErr?.message }) } @@ -1725,7 +1745,7 @@ const subAgentSSEHandlers: Record = { const hasInterruptDisplays = !!instance.getInterruptDisplays?.() if (!hasInterruptDisplays) { try { - await instance.execute(args) + await instance.execute(args || {}) } catch (execErr: any) { logger.error('[SubAgent] Class tool execution failed', { id, name, error: execErr?.message }) } From 442d8a1f45a464d325bac1186ea5d17d9c6012e0 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 6 Jan 2026 11:19:57 -0800 Subject: [PATCH 05/39] Message queue --- .../components/copilot/components/index.ts | 1 + .../queued-messages/queued-messages.tsx | 108 ++++++++++++++++++ .../components/user-input/user-input.tsx | 3 +- .../panel/components/copilot/copilot.tsx | 9 +- apps/sim/stores/panel/copilot/store.ts | 87 +++++++++++++- apps/sim/stores/panel/copilot/types.ts | 28 +++++ 6 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/queued-messages/queued-messages.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/index.ts index 28de03e6cb..3ac19aac2d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/index.ts @@ -1,5 +1,6 @@ export * from './copilot-message/copilot-message' export * from './plan-mode-section/plan-mode-section' +export * from './queued-messages/queued-messages' export * from './todo-list/todo-list' export * from './tool-call/tool-call' export * from './user-input/user-input' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/queued-messages/queued-messages.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/queued-messages/queued-messages.tsx new file mode 100644 index 0000000000..e39fa78e15 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/queued-messages/queued-messages.tsx @@ -0,0 +1,108 @@ +'use client' + +import { useCallback, useState } from 'react' +import { ArrowUp, ChevronDown, ChevronRight, MoreHorizontal, Trash2 } from 'lucide-react' +import { useCopilotStore } from '@/stores/panel/copilot/store' + +/** + * Displays queued messages in a Cursor-style collapsible panel above the input box. + */ +export function QueuedMessages() { + const messageQueue = useCopilotStore((s) => s.messageQueue) + const removeFromQueue = useCopilotStore((s) => s.removeFromQueue) + const sendNow = useCopilotStore((s) => s.sendNow) + + const [isExpanded, setIsExpanded] = useState(true) + + const handleRemove = useCallback( + (id: string) => { + removeFromQueue(id) + }, + [removeFromQueue] + ) + + const handleSendNow = useCallback( + async (id: string) => { + await sendNow(id) + }, + [sendNow] + ) + + if (messageQueue.length === 0) return null + + return ( +
+ {/* Header */} + + + {/* Message list */} + {isExpanded && ( +
+ {messageQueue.map((msg, index) => ( +
+ {/* Radio indicator */} +
+
+
+ + {/* Message content */} +
+

+ {msg.content} +

+
+ + {/* Actions */} +
+ {/* Send immediately button */} + + {/* Delete button */} + +
+
+ ))} +
+ )} +
+ ) +} + 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 c88aed1414..99b272f9f3 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 @@ -300,7 +300,8 @@ const UserInput = forwardRef( async (overrideMessage?: string, options: { preserveInput?: boolean } = {}) => { const targetMessage = overrideMessage ?? message const trimmedMessage = targetMessage.trim() - if (!trimmedMessage || disabled || isLoading) return + // Allow submission even when isLoading - store will queue the message + if (!trimmedMessage || disabled) return const failedUploads = fileAttachments.attachedFiles.filter((f) => !f.uploading && !f.key) if (failedUploads.length > 0) { 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 cd47ec91a6..b2ab74106f 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 @@ -25,6 +25,7 @@ import { Trash } from '@/components/emcn/icons/trash' import { CopilotMessage, PlanModeSection, + QueuedMessages, TodoList, UserInput, Welcome, @@ -298,7 +299,8 @@ export const Copilot = forwardRef(({ panelWidth }, ref */ const handleSubmit = useCallback( async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: any[]) => { - if (!query || isSendingMessage || !activeWorkflowId) return + // Allow submission even when isSendingMessage - store will queue the message + if (!query || !activeWorkflowId) return if (showPlanTodos) { const store = useCopilotStore.getState() @@ -316,7 +318,7 @@ export const Copilot = forwardRef(({ panelWidth }, ref logger.error('Failed to send message:', error) } }, - [isSendingMessage, activeWorkflowId, sendMessage, showPlanTodos] + [activeWorkflowId, sendMessage, showPlanTodos] ) /** @@ -588,6 +590,9 @@ export const Copilot = forwardRef(({ panelWidth }, ref )}
+ {/* Queued messages (shown when messages are waiting) */} + + {/* Input area with integrated mode selector */}
()( @@ -2301,7 +2302,7 @@ export const useCopilotStore = create()( // Send a message (streaming only) sendMessage: async (message: string, options = {}) => { - const { workflowId, currentChat, mode, revertState } = get() + const { workflowId, currentChat, mode, revertState, isSendingMessage } = get() const { stream = true, fileAttachments, @@ -2316,6 +2317,13 @@ export const useCopilotStore = create()( if (!workflowId) return + // If already sending a message, queue this one instead + if (isSendingMessage) { + get().addToQueue(message, { fileAttachments, contexts }) + logger.info('[Copilot] Message queued (already sending)', { queueLength: get().messageQueue.length + 1 }) + return + } + const abortController = new AbortController() set({ isSendingMessage: true, error: null, abortController }) @@ -3042,6 +3050,23 @@ export const useCopilotStore = create()( await get().handleNewChatCreation(context.newChatId) } + // Process next message in queue if any + const nextInQueue = get().messageQueue[0] + if (nextInQueue) { + logger.info('[Queue] Processing next queued message', { id: nextInQueue.id, queueLength: get().messageQueue.length }) + // Remove from queue and send + get().removeFromQueue(nextInQueue.id) + // Use setTimeout to avoid blocking the current execution + setTimeout(() => { + get().sendMessage(nextInQueue.content, { + stream: true, + fileAttachments: nextInQueue.fileAttachments, + contexts: nextInQueue.contexts, + messageId: nextInQueue.id, + }) + }, 100) + } + // Persist full message state (including contentBlocks), plan artifact, and config to database const { currentChat, streamingPlanContent, mode, selectedModel } = get() if (currentChat) { @@ -3524,6 +3549,66 @@ export const useCopilotStore = create()( const { autoAllowedTools } = get() return autoAllowedTools.includes(toolId) }, + + // Message queue actions + addToQueue: (message, options) => { + const queuedMessage: import('./types').QueuedMessage = { + id: crypto.randomUUID(), + content: message, + fileAttachments: options?.fileAttachments, + contexts: options?.contexts, + queuedAt: Date.now(), + } + set({ messageQueue: [...get().messageQueue, queuedMessage] }) + logger.info('[Queue] Message added to queue', { id: queuedMessage.id, queueLength: get().messageQueue.length }) + }, + + removeFromQueue: (id) => { + set({ messageQueue: get().messageQueue.filter((m) => m.id !== id) }) + logger.info('[Queue] Message removed from queue', { id, queueLength: get().messageQueue.length }) + }, + + moveUpInQueue: (id) => { + const queue = [...get().messageQueue] + const index = queue.findIndex((m) => m.id === id) + if (index > 0) { + const item = queue[index] + queue.splice(index, 1) + queue.splice(index - 1, 0, item) + set({ messageQueue: queue }) + logger.info('[Queue] Message moved up in queue', { id, newIndex: index - 1 }) + } + }, + + sendNow: async (id) => { + const queue = get().messageQueue + const message = queue.find((m) => m.id === id) + if (!message) return + + // Remove from queue first + get().removeFromQueue(id) + + // If currently sending, abort and send this one + const { isSendingMessage } = get() + if (isSendingMessage) { + get().abortMessage() + // Wait a tick for abort to complete + await new Promise((resolve) => setTimeout(resolve, 50)) + } + + // Send the message + await get().sendMessage(message.content, { + stream: true, + fileAttachments: message.fileAttachments, + contexts: message.contexts, + messageId: message.id, + }) + }, + + clearQueue: () => { + set({ messageQueue: [] }) + logger.info('[Queue] Queue cleared') + }, })) ) diff --git a/apps/sim/stores/panel/copilot/types.ts b/apps/sim/stores/panel/copilot/types.ts index 5e9a987a47..ee9c9a8f58 100644 --- a/apps/sim/stores/panel/copilot/types.ts +++ b/apps/sim/stores/panel/copilot/types.ts @@ -60,6 +60,18 @@ export interface CopilotMessage { errorType?: 'usage_limit' | 'unauthorized' | 'forbidden' | 'rate_limit' | 'upgrade_required' } +/** + * A message queued for sending while another message is in progress. + * Like Cursor's queued message feature. + */ +export interface QueuedMessage { + id: string + content: string + fileAttachments?: MessageFileAttachment[] + contexts?: ChatContext[] + queuedAt: number +} + // Contexts attached to a user message export type ChatContext = | { kind: 'past_chat'; chatId: string; label: string } @@ -161,6 +173,9 @@ export interface CopilotState { // Auto-allowed integration tools (tools that can run without confirmation) autoAllowedTools: string[] + + // Message queue for messages sent while another is in progress + messageQueue: QueuedMessage[] } export interface CopilotActions { @@ -238,6 +253,19 @@ export interface CopilotActions { addAutoAllowedTool: (toolId: string) => Promise removeAutoAllowedTool: (toolId: string) => Promise isToolAutoAllowed: (toolId: string) => boolean + + // Message queue actions + addToQueue: ( + message: string, + options?: { + fileAttachments?: MessageFileAttachment[] + contexts?: ChatContext[] + } + ) => void + removeFromQueue: (id: string) => void + moveUpInQueue: (id: string) => void + sendNow: (id: string) => Promise + clearQueue: () => void } export type CopilotStore = CopilotState & CopilotActions From 7a925ad45c0167e410edeb3dde83cd1e97817494 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 6 Jan 2026 14:26:28 -0800 Subject: [PATCH 06/39] Many subagents --- .../queued-messages/queued-messages.tsx | 18 ++++---- .../components/tool-call/tool-call.tsx | 34 +++++++++++--- apps/sim/lib/copilot/registry.ts | 6 +-- .../tools/client/blocks/get-block-options.ts | 11 ++++- .../lib/copilot/tools/client/other/auth.ts | 45 +++++++++++++++++++ .../copilot/tools/client/other/custom-tool.ts | 45 +++++++++++++++++++ .../lib/copilot/tools/client/other/debug.ts | 2 +- .../lib/copilot/tools/client/other/deploy.ts | 45 +++++++++++++++++++ .../client/other/{apply-edit.ts => edit.ts} | 28 ++++++------ .../lib/copilot/tools/client/other/info.ts | 45 +++++++++++++++++++ .../copilot/tools/client/other/knowledge.ts | 45 +++++++++++++++++++ .../lib/copilot/tools/client/other/plan.ts | 2 +- .../copilot/tools/client/other/research.ts | 45 +++++++++++++++++++ .../lib/copilot/tools/client/other/test.ts | 45 +++++++++++++++++++ .../lib/copilot/tools/client/other/tour.ts | 45 +++++++++++++++++++ .../client/workflow/manage-custom-tool.ts | 27 ++++++++--- .../tools/server/blocks/get-block-config.ts | 3 +- .../server/blocks/get-blocks-metadata-tool.ts | 7 +-- apps/sim/stores/panel/copilot/store.ts | 34 +++++++++++--- 19 files changed, 478 insertions(+), 54 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/client/other/auth.ts create mode 100644 apps/sim/lib/copilot/tools/client/other/custom-tool.ts create mode 100644 apps/sim/lib/copilot/tools/client/other/deploy.ts rename apps/sim/lib/copilot/tools/client/other/{apply-edit.ts => edit.ts} (51%) create mode 100644 apps/sim/lib/copilot/tools/client/other/info.ts create mode 100644 apps/sim/lib/copilot/tools/client/other/knowledge.ts create mode 100644 apps/sim/lib/copilot/tools/client/other/research.ts create mode 100644 apps/sim/lib/copilot/tools/client/other/test.ts create mode 100644 apps/sim/lib/copilot/tools/client/other/tour.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/queued-messages/queued-messages.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/queued-messages/queued-messages.tsx index e39fa78e15..dda35af434 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/queued-messages/queued-messages.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/queued-messages/queued-messages.tsx @@ -54,7 +54,7 @@ export function QueuedMessages() { {/* Message list */} {isExpanded && (
- {messageQueue.map((msg, index) => ( + {messageQueue.map((msg) => (
- {/* Actions */} -
- {/* Send immediately button */} + {/* Actions - always visible */} +
- {/* Delete button */}
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 446a173629..6d3a35874a 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 @@ -238,11 +238,13 @@ function SubAgentToolCall({ toolCall }: { toolCall: CopilotToolCall }) { toolCall.state === ClientToolCallState.pending || toolCall.state === ClientToolCallState.executing + const showButtons = shouldShowRunSkipButtons(toolCall) + return (
{displayName} - {isLoading && ( + {isLoading && !showButtons && ( + {showButtons && }
) } From 4fd5656c01435cba9ebd6da2fab8908dff2e8ca8 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 6 Jan 2026 18:51:02 -0800 Subject: [PATCH 10/39] Diff in chat --- .../components/tool-call/tool-call.tsx | 207 +++++++++++++++++- 1 file changed, 199 insertions(+), 8 deletions(-) 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 6eb8610e39..6fab484651 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 @@ -2,13 +2,16 @@ import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' -import { ChevronUp } from 'lucide-react' +import { ChevronDown, ChevronUp } from 'lucide-react' import { Button, Code } 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' +import { getBlock } from '@/blocks/registry' import { CLASS_TOOL_METADATA, useCopilotStore } from '@/stores/panel/copilot/store' import type { CopilotToolCall, SubAgentContentBlock } from '@/stores/panel/copilot/types' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' interface ToolCallProps { toolCall?: CopilotToolCall @@ -231,7 +234,13 @@ function ShimmerOverlayText({ /** * SubAgentToolCall renders a nested tool call from a subagent in a muted/thinking style. */ -function SubAgentToolCall({ toolCall }: { toolCall: CopilotToolCall }) { +function SubAgentToolCall({ toolCall: toolCallProp }: { toolCall: CopilotToolCall }) { + // Get live toolCall from store to ensure we have the latest state and params + const liveToolCall = useCopilotStore((s) => + toolCallProp.id ? s.toolCallsById[toolCallProp.id] : undefined + ) + const toolCall = liveToolCall || toolCallProp + const displayName = getDisplayNameForSubAgent(toolCall) const isLoading = @@ -360,15 +369,23 @@ function SubAgentToolCall({ toolCall }: { toolCall: CopilotToolCall }) { return null } + // For edit_workflow, only show the WorkflowEditSummary component (replaces text display) + const isEditWorkflow = toolCall.name === 'edit_workflow' + const hasOperations = Array.isArray(params.operations) && params.operations.length > 0 + return (
- + {/* Hide text display for edit_workflow when we have operations to show in summary */} + {!(isEditWorkflow && hasOperations) && ( + + )} {renderSubAgentTable()} + {showButtons && }
) @@ -584,6 +601,177 @@ function isSpecialToolCall(toolCall: CopilotToolCall): boolean { return workflowOperationTools.includes(toolCall.name) } +/** + * WorkflowEditSummary shows a full-width summary of workflow edits (like Cursor's diff). + * Displays: workflow name with stats (+N green, N orange, -N red) + * Expands inline on click to show individual blocks with their icons. + */ +function WorkflowEditSummary({ toolCall }: { toolCall: CopilotToolCall }) { + const [isExpanded, setIsExpanded] = useState(false) + + // Get workflow name from registry + const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) + const workflows = useWorkflowRegistry((s) => s.workflows) + const workflowName = activeWorkflowId ? workflows[activeWorkflowId]?.name : undefined + + // Get block data from current workflow state + const blocks = useWorkflowStore((s) => s.blocks) + + // Show for edit_workflow regardless of state + if (toolCall.name !== 'edit_workflow') { + return null + } + + // Extract operations from tool call params + const params = (toolCall as any).parameters || (toolCall as any).input || (toolCall as any).params || {} + let operations = Array.isArray(params.operations) ? params.operations : [] + + // Fallback: check if operations are at top level of toolCall + if (operations.length === 0 && Array.isArray((toolCall as any).operations)) { + operations = (toolCall as any).operations + } + + // Group operations by type with block info + interface BlockChange { + blockId: string + blockName: string + blockType: string + } + + const addedBlocks: BlockChange[] = [] + const editedBlocks: BlockChange[] = [] + const deletedBlocks: BlockChange[] = [] + + for (const op of operations) { + const blockId = op.block_id + if (!blockId) continue + + // Get block info from current workflow state or operation params + const currentBlock = blocks[blockId] + let blockName = currentBlock?.name || '' + let blockType = currentBlock?.type || '' + + // For add operations, get info from params (type is stored as params.type) + if (op.operation_type === 'add' && op.params) { + blockName = blockName || op.params.name || '' + blockType = blockType || op.params.type || '' + } + + // For edit operations, also check params.type if block not in current state + if (op.operation_type === 'edit' && op.params && !blockType) { + blockType = op.params.type || '' + } + + // Fallback name to type or ID + if (!blockName) blockName = blockType || blockId + + const change: BlockChange = { blockId, blockName, blockType } + + switch (op.operation_type) { + case 'add': + addedBlocks.push(change) + break + case 'edit': + editedBlocks.push(change) + break + case 'delete': + deletedBlocks.push(change) + break + } + } + + const hasChanges = addedBlocks.length > 0 || editedBlocks.length > 0 || deletedBlocks.length > 0 + + if (!hasChanges) { + return null + } + + // Get block config by type (for icon and bgColor) + const getBlockConfig = (blockType: string) => { + return getBlock(blockType) + } + + // Render a single block row (toolbar style: colored square with white icon) + const renderBlockRow = ( + change: BlockChange, + type: 'add' | 'edit' | 'delete' + ) => { + const blockConfig = getBlockConfig(change.blockType) + const Icon = blockConfig?.icon + const bgColor = blockConfig?.bgColor || '#6B7280' + + const symbols = { + add: { symbol: '+', color: 'text-[#22c55e]' }, + edit: { symbol: '•', color: 'text-[#f97316]' }, + delete: { symbol: '-', color: 'text-[#ef4444]' }, + } + const { symbol, color } = symbols[type] + + return ( +
+ {symbol} + {/* Toolbar-style icon: colored square with white icon */} +
+ {Icon && } +
+ + {change.blockName} + +
+ ) + } + + return ( +
+ {/* Header row - always visible */} + + + {/* Expanded block list */} + {isExpanded && ( +
+ {addedBlocks.map((change) => renderBlockRow(change, 'add'))} + {editedBlocks.map((change) => renderBlockRow(change, 'edit'))} + {deletedBlocks.map((change) => renderBlockRow(change, 'delete'))} +
+ )} +
+ ) +} + /** * Checks if a tool is an integration tool (server-side executed, not a client tool) */ @@ -1515,6 +1703,9 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
) : null} + {/* Workflow edit summary - shows block changes after edit_workflow completes */} + + {/* Render subagent content (from debug tool or other subagents) */} {toolCall.subAgentBlocks && toolCall.subAgentBlocks.length > 0 && ( Date: Tue, 6 Jan 2026 19:18:25 -0800 Subject: [PATCH 11/39] Remove context usage code --- .../app/api/copilot/context-usage/route.ts | 134 ------------------ .../context-usage-indicator.tsx | 76 ---------- .../components/user-input/components/index.ts | 1 - .../components/user-input/user-input.tsx | 1 - .../panel/components/copilot/copilot.tsx | 2 - .../hooks/use-copilot-initialization.ts | 14 -- apps/sim/stores/panel/copilot/store.ts | 96 +------------ apps/sim/stores/panel/copilot/types.ts | 11 -- 8 files changed, 1 insertion(+), 334 deletions(-) delete mode 100644 apps/sim/app/api/copilot/context-usage/route.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/context-usage-indicator/context-usage-indicator.tsx diff --git a/apps/sim/app/api/copilot/context-usage/route.ts b/apps/sim/app/api/copilot/context-usage/route.ts deleted file mode 100644 index ac8f834327..0000000000 --- a/apps/sim/app/api/copilot/context-usage/route.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { getSession } from '@/lib/auth' -import { getCopilotModel } from '@/lib/copilot/config' -import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/copilot/constants' -import type { CopilotProviderConfig } from '@/lib/copilot/types' -import { env } from '@/lib/core/config/env' - -const logger = createLogger('ContextUsageAPI') - -const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT - -const ContextUsageRequestSchema = z.object({ - chatId: z.string(), - model: z.string(), - workflowId: z.string(), - provider: z.any().optional(), -}) - -/** - * POST /api/copilot/context-usage - * Fetch context usage from sim-agent API - */ -export async function POST(req: NextRequest) { - try { - logger.info('[Context Usage API] Request received') - - const session = await getSession() - if (!session?.user?.id) { - logger.warn('[Context Usage API] No session/user ID') - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const body = await req.json() - logger.info('[Context Usage API] Request body', body) - - const parsed = ContextUsageRequestSchema.safeParse(body) - - if (!parsed.success) { - logger.warn('[Context Usage API] Invalid request body', parsed.error.errors) - return NextResponse.json( - { error: 'Invalid request body', details: parsed.error.errors }, - { status: 400 } - ) - } - - const { chatId, model, workflowId, provider } = parsed.data - const userId = session.user.id // Get userId from session, not from request - - logger.info('[Context Usage API] Request validated', { chatId, model, userId, workflowId }) - - // Build provider config similar to chat route - let providerConfig: CopilotProviderConfig | undefined = provider - if (!providerConfig) { - const defaults = getCopilotModel('chat') - const modelToUse = env.COPILOT_MODEL || defaults.model - const providerEnv = env.COPILOT_PROVIDER as any - - if (providerEnv) { - if (providerEnv === 'azure-openai') { - providerConfig = { - provider: 'azure-openai', - model: modelToUse, - apiKey: env.AZURE_OPENAI_API_KEY, - apiVersion: env.AZURE_OPENAI_API_VERSION, - endpoint: env.AZURE_OPENAI_ENDPOINT, - } - } else if (providerEnv === 'vertex') { - providerConfig = { - provider: 'vertex', - model: modelToUse, - apiKey: env.COPILOT_API_KEY, - vertexProject: env.VERTEX_PROJECT, - vertexLocation: env.VERTEX_LOCATION, - } - } else { - providerConfig = { - provider: providerEnv, - model: modelToUse, - apiKey: env.COPILOT_API_KEY, - } - } - } - } - - // Call sim-agent API - const requestPayload = { - chatId, - model, - userId, - workflowId, - ...(providerConfig ? { provider: providerConfig } : {}), - } - - logger.info('[Context Usage API] Calling sim-agent', { - url: `${SIM_AGENT_API_URL}/api/get-context-usage`, - payload: requestPayload, - }) - - const simAgentResponse = await fetch(`${SIM_AGENT_API_URL}/api/get-context-usage`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}), - }, - body: JSON.stringify(requestPayload), - }) - - logger.info('[Context Usage API] Sim-agent response', { - status: simAgentResponse.status, - ok: simAgentResponse.ok, - }) - - if (!simAgentResponse.ok) { - const errorText = await simAgentResponse.text().catch(() => '') - logger.warn('[Context Usage API] Sim agent request failed', { - status: simAgentResponse.status, - error: errorText, - }) - return NextResponse.json( - { error: 'Failed to fetch context usage from sim-agent' }, - { status: simAgentResponse.status } - ) - } - - const data = await simAgentResponse.json() - logger.info('[Context Usage API] Sim-agent data received', data) - return NextResponse.json(data) - } catch (error) { - logger.error('Error fetching context usage:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/context-usage-indicator/context-usage-indicator.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/context-usage-indicator/context-usage-indicator.tsx deleted file mode 100644 index 26b00f89ef..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/context-usage-indicator/context-usage-indicator.tsx +++ /dev/null @@ -1,76 +0,0 @@ -'use client' - -import { useMemo } from 'react' -import { Tooltip } from '@/components/emcn' - -interface ContextUsageIndicatorProps { - /** Usage percentage (0-100) */ - percentage: number - /** Size of the indicator in pixels */ - size?: number - /** Stroke width in pixels */ - strokeWidth?: number -} - -/** - * Circular context usage indicator showing percentage of context window used. - * Displays a progress ring that changes color based on usage level. - * - * @param props - Component props - * @returns Rendered context usage indicator - */ -export function ContextUsageIndicator({ - percentage, - size = 20, - strokeWidth = 2, -}: ContextUsageIndicatorProps) { - const radius = (size - strokeWidth) / 2 - const circumference = radius * 2 * Math.PI - const offset = circumference - (percentage / 100) * circumference - - const color = useMemo(() => { - if (percentage >= 90) return 'var(--text-error)' - if (percentage >= 75) return 'var(--warning)' - return 'var(--text-muted)' - }, [percentage]) - - const displayPercentage = useMemo(() => { - return Math.round(percentage) - }, [percentage]) - - return ( - - -
- - - - -
-
- {displayPercentage}% context used -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/index.ts index 071d26475b..fd7d64cff1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/index.ts @@ -1,6 +1,5 @@ export { AttachedFilesDisplay } from './attached-files-display/attached-files-display' export { ContextPills } from './context-pills/context-pills' -export { ContextUsageIndicator } from './context-usage-indicator/context-usage-indicator' export { MentionMenu } from './mention-menu/mention-menu' export { ModeSelector } from './mode-selector/mode-selector' export { ModelSelector } from './model-selector/model-selector' 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 99b272f9f3..2b705b83ed 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 @@ -117,7 +117,6 @@ const UserInput = forwardRef( const selectedModel = selectedModelOverride !== undefined ? selectedModelOverride : copilotStore.selectedModel const setSelectedModel = onModelChangeOverride || copilotStore.setSelectedModel - const contextUsage = copilotStore.contextUsage // Internal state const [internalMessage, setInternalMessage] = useState('') 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 b2ab74106f..bbef0b876e 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 @@ -100,7 +100,6 @@ export const Copilot = forwardRef(({ panelWidth }, ref loadChats, messageCheckpoints, currentChat, - fetchContextUsage, selectChat, deleteChat, areChatsFresh, @@ -119,7 +118,6 @@ export const Copilot = forwardRef(({ panelWidth }, ref chatsLoadedForWorkflow, setCopilotWorkflowId, loadChats, - fetchContextUsage, loadAutoAllowedTools, currentChat, isSendingMessage, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/hooks/use-copilot-initialization.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/hooks/use-copilot-initialization.ts index 719760ce2a..d02c3b89a8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/hooks/use-copilot-initialization.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/hooks/use-copilot-initialization.ts @@ -11,7 +11,6 @@ interface UseCopilotInitializationProps { chatsLoadedForWorkflow: string | null setCopilotWorkflowId: (workflowId: string | null) => Promise loadChats: (forceRefresh?: boolean) => Promise - fetchContextUsage: () => Promise loadAutoAllowedTools: () => Promise currentChat: any isSendingMessage: boolean @@ -30,7 +29,6 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) { chatsLoadedForWorkflow, setCopilotWorkflowId, loadChats, - fetchContextUsage, loadAutoAllowedTools, currentChat, isSendingMessage, @@ -102,18 +100,6 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) { isSendingMessage, ]) - /** - * Fetch context usage when component is initialized and has a current chat - */ - useEffect(() => { - if (isInitialized && currentChat?.id && activeWorkflowId) { - logger.info('[Copilot] Component initialized, fetching context usage') - fetchContextUsage().catch((err) => { - logger.warn('[Copilot] Failed to fetch context usage on mount', err) - }) - } - }, [isInitialized, currentChat?.id, activeWorkflowId, fetchContextUsage]) - /** * Load auto-allowed tools once on mount */ diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index be14991bc3..41dd27ec66 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -1969,7 +1969,6 @@ const initialState = { streamingPlanContent: '', toolCallsById: {} as Record, suppressAutoSelect: false, - contextUsage: null, autoAllowedTools: [] as string[], messageQueue: [] as import('./types').QueuedMessage[], } @@ -1982,7 +1981,7 @@ export const useCopilotStore = create()( setMode: (mode) => set({ mode }), // Clear messages (don't clear streamingPlanContent - let it persist) - clearMessages: () => set({ messages: [], contextUsage: null }), + clearMessages: () => set({ messages: [] }), // Workflow selection setWorkflowId: async (workflowId: string | null) => { @@ -2060,7 +2059,6 @@ export const useCopilotStore = create()( mode: chatMode, selectedModel: chatModel as CopilotStore['selectedModel'], suppressAutoSelect: false, - contextUsage: null, }) // Background-save the previous chat's latest messages, plan artifact, and config before switching (optimistic) @@ -2112,15 +2110,11 @@ export const useCopilotStore = create()( chats: (get().chats || []).map((c: CopilotChat) => c.id === chat.id ? latestChat : c ), - contextUsage: null, toolCallsById, }) try { await get().loadMessageCheckpoints(latestChat.id) } catch {} - // Fetch context usage for the selected chat - logger.info('[Context Usage] Chat selected, fetching usage') - await get().fetchContextUsage() } } } catch {} @@ -2158,7 +2152,6 @@ export const useCopilotStore = create()( } } catch {} - logger.info('[Context Usage] New chat created, clearing context usage') set({ currentChat: null, messages: [], @@ -2167,7 +2160,6 @@ export const useCopilotStore = create()( showPlanTodos: false, streamingPlanContent: '', suppressAutoSelect: true, - contextUsage: null, }) }, @@ -2560,13 +2552,6 @@ export const useCopilotStore = create()( } catch {} } - // Fetch context usage after abort - logger.info('[Context Usage] Message aborted, fetching usage') - get() - .fetchContextUsage() - .catch((err) => { - logger.warn('[Context Usage] Failed to fetch after abort', err) - }) } catch { set({ isSendingMessage: false, isAborting: false, abortController: null }) } @@ -3132,10 +3117,6 @@ export const useCopilotStore = create()( // Removed: stats sending now occurs only on accept/reject with minimal payload } catch {} - // Fetch context usage after response completes - logger.info('[Context Usage] Stream completed, fetching usage') - await get().fetchContextUsage() - // Invalidate subscription queries to update usage setTimeout(() => { const queryClient = getQueryClient() @@ -3313,86 +3294,11 @@ export const useCopilotStore = create()( }, setSelectedModel: async (model) => { - logger.info('[Context Usage] Model changed', { from: get().selectedModel, to: model }) set({ selectedModel: model }) - // Fetch context usage after model switch - await get().fetchContextUsage() }, setAgentPrefetch: (prefetch) => set({ agentPrefetch: prefetch }), setEnabledModels: (models) => set({ enabledModels: models }), - // Fetch context usage from sim-agent API - fetchContextUsage: async () => { - try { - const { currentChat, selectedModel, workflowId } = get() - logger.info('[Context Usage] Starting fetch', { - hasChatId: !!currentChat?.id, - hasWorkflowId: !!workflowId, - chatId: currentChat?.id, - workflowId, - model: selectedModel, - }) - - if (!currentChat?.id || !workflowId) { - logger.info('[Context Usage] Skipping: missing chat or workflow', { - hasChatId: !!currentChat?.id, - hasWorkflowId: !!workflowId, - }) - return - } - - const requestPayload = { - chatId: currentChat.id, - model: selectedModel, - workflowId, - } - - logger.info('[Context Usage] Calling API', requestPayload) - - // Call the backend API route which proxies to sim-agent - const response = await fetch('/api/copilot/context-usage', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestPayload), - }) - - logger.info('[Context Usage] API response', { status: response.status, ok: response.ok }) - - if (response.ok) { - const data = await response.json() - logger.info('[Context Usage] Received data', data) - - // Check for either tokensUsed or usage field - if ( - data.tokensUsed !== undefined || - data.usage !== undefined || - data.percentage !== undefined - ) { - const contextUsage = { - usage: data.tokensUsed || data.usage || 0, - percentage: data.percentage || 0, - model: data.model || selectedModel, - contextWindow: data.contextWindow || data.context_window || 0, - when: data.when || 'end', - estimatedTokens: data.tokensUsed || data.estimated_tokens || data.estimatedTokens, - } - set({ contextUsage }) - logger.info('[Context Usage] Updated store', contextUsage) - } else { - logger.warn('[Context Usage] No usage data in response', data) - } - } else { - const errorText = await response.text().catch(() => 'Unable to read error') - logger.warn('[Context Usage] API call failed', { - status: response.status, - error: errorText, - }) - } - } catch (err) { - logger.error('[Context Usage] Error fetching:', err) - } - }, - executeIntegrationTool: async (toolCallId: string) => { const { toolCallsById, workflowId } = get() const toolCall = toolCallsById[toolCallId] diff --git a/apps/sim/stores/panel/copilot/types.ts b/apps/sim/stores/panel/copilot/types.ts index ee9c9a8f58..bf9b210d88 100644 --- a/apps/sim/stores/panel/copilot/types.ts +++ b/apps/sim/stores/panel/copilot/types.ts @@ -161,16 +161,6 @@ export interface CopilotState { // Per-message metadata captured at send-time for reliable stats - // Context usage tracking for percentage pill - contextUsage: { - usage: number - percentage: number - model: string - contextWindow: number - when: 'start' | 'end' - estimatedTokens?: number - } | null - // Auto-allowed integration tools (tools that can run without confirmation) autoAllowedTools: string[] @@ -183,7 +173,6 @@ export interface CopilotActions { setSelectedModel: (model: CopilotStore['selectedModel']) => Promise setAgentPrefetch: (prefetch: boolean) => void setEnabledModels: (models: string[] | null) => void - fetchContextUsage: () => Promise setWorkflowId: (workflowId: string | null) => Promise validateCurrentChat: () => boolean From 2fe0afaef4e0ba51594f5d53b37c3d4e38a0bc75 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 7 Jan 2026 12:12:05 -0800 Subject: [PATCH 12/39] Diff view in chat --- .../components/thinking-block.tsx | 2 +- .../components/tool-call/tool-call.tsx | 224 ++++++++++++------ 2 files changed, 157 insertions(+), 69 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx index 95c390d388..7b09fa09ec 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx @@ -7,7 +7,7 @@ import { ChevronUp } from 'lucide-react' /** * Max height for thinking content before internal scrolling kicks in */ -const THINKING_MAX_HEIGHT = 125 +const THINKING_MAX_HEIGHT = 200 /** * Interval for auto-scroll during streaming (ms) 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 6fab484651..0c4d2fb333 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 @@ -385,7 +385,7 @@ function SubAgentToolCall({ toolCall: toolCallProp }: { toolCall: CopilotToolCal /> )} {renderSubAgentTable()} - + {/* WorkflowEditSummary is rendered outside SubAgentContent for edit subagent */} {showButtons && }
) @@ -406,7 +406,7 @@ function getDisplayNameForSubAgent(toolCall: CopilotToolCall): string { /** * Max height for subagent content before internal scrolling kicks in */ -const SUBAGENT_MAX_HEIGHT = 125 +const SUBAGENT_MAX_HEIGHT = 200 /** * Interval for auto-scroll during streaming (ms) @@ -582,6 +582,16 @@ function SubAgentContent({ })} )} + + {/* Render WorkflowEditSummary outside the collapsible container for edit_workflow tool calls */} + {blocks + .filter((block) => block.type === 'subagent_tool_call' && block.toolCall?.name === 'edit_workflow') + .map((block, index) => ( + + ))} ) } @@ -607,16 +617,25 @@ function isSpecialToolCall(toolCall: CopilotToolCall): boolean { * Expands inline on click to show individual blocks with their icons. */ function WorkflowEditSummary({ toolCall }: { toolCall: CopilotToolCall }) { - const [isExpanded, setIsExpanded] = useState(false) - - // Get workflow name from registry - const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) - const workflows = useWorkflowRegistry((s) => s.workflows) - const workflowName = activeWorkflowId ? workflows[activeWorkflowId]?.name : undefined - // Get block data from current workflow state const blocks = useWorkflowStore((s) => s.blocks) + // Cache block info on first render (before diff is applied) so we can show + // deleted blocks properly even after they're removed from the workflow + const cachedBlockInfoRef = useRef>({}) + + // Update cache with current block info (only add, never remove) + useEffect(() => { + for (const [blockId, block] of Object.entries(blocks)) { + if (!cachedBlockInfoRef.current[blockId]) { + cachedBlockInfoRef.current[blockId] = { + name: block.name || '', + type: block.type || '', + } + } + } + }, [blocks]) + // Show for edit_workflow regardless of state if (toolCall.name !== 'edit_workflow') { return null @@ -632,10 +651,19 @@ function WorkflowEditSummary({ toolCall }: { toolCall: CopilotToolCall }) { } // Group operations by type with block info + interface SubBlockPreview { + id: string + value: any + } + interface BlockChange { blockId: string blockName: string blockType: string + /** All subblocks for add operations */ + subBlocks?: SubBlockPreview[] + /** Only changed subblocks for edit operations */ + changedSubBlocks?: SubBlockPreview[] } const addedBlocks: BlockChange[] = [] @@ -646,10 +674,11 @@ function WorkflowEditSummary({ toolCall }: { toolCall: CopilotToolCall }) { const blockId = op.block_id if (!blockId) continue - // Get block info from current workflow state or operation params + // Get block info from current workflow state, cached state, or operation params const currentBlock = blocks[blockId] - let blockName = currentBlock?.name || '' - let blockType = currentBlock?.type || '' + const cachedBlock = cachedBlockInfoRef.current[blockId] + let blockName = currentBlock?.name || cachedBlock?.name || '' + let blockType = currentBlock?.type || cachedBlock?.type || '' // For add operations, get info from params (type is stored as params.type) if (op.operation_type === 'add' && op.params) { @@ -662,11 +691,45 @@ function WorkflowEditSummary({ toolCall }: { toolCall: CopilotToolCall }) { blockType = op.params.type || '' } + // Skip edge-only edit operations (like how we don't highlight blocks on canvas for edge changes) + // An edit is edge-only if params only contains 'connections' and nothing else meaningful + if (op.operation_type === 'edit' && op.params) { + const paramKeys = Object.keys(op.params) + const isEdgeOnlyEdit = paramKeys.length === 1 && paramKeys[0] === 'connections' + if (isEdgeOnlyEdit) { + continue + } + } + + // For delete operations, check if block info was provided in operation + if (op.operation_type === 'delete') { + // Some delete operations may include block_name and block_type + blockName = blockName || op.block_name || '' + blockType = blockType || op.block_type || '' + } + // Fallback name to type or ID if (!blockName) blockName = blockType || blockId const change: BlockChange = { blockId, blockName, blockType } + // Extract subblock info from operation params + if (op.params?.inputs && typeof op.params.inputs === 'object') { + const subBlocks: SubBlockPreview[] = [] + for (const [id, value] of Object.entries(op.params.inputs)) { + // Skip empty values and connections + if (value === null || value === undefined || value === '') continue + subBlocks.push({ id, value }) + } + if (subBlocks.length > 0) { + if (op.operation_type === 'add') { + change.subBlocks = subBlocks + } else if (op.operation_type === 'edit') { + change.changedSubBlocks = subBlocks + } + } + } + switch (op.operation_type) { case 'add': addedBlocks.push(change) @@ -691,8 +754,40 @@ function WorkflowEditSummary({ toolCall }: { toolCall: CopilotToolCall }) { return getBlock(blockType) } - // Render a single block row (toolbar style: colored square with white icon) - const renderBlockRow = ( + // Format subblock value for display + const formatSubBlockValue = (value: any): string => { + if (value === null || value === undefined) return '' + if (typeof value === 'string') { + // Truncate long strings + return value.length > 60 ? `${value.slice(0, 60)}...` : value + } + if (typeof value === 'boolean') return value ? 'true' : 'false' + if (typeof value === 'number') return String(value) + if (Array.isArray(value)) { + if (value.length === 0) return '[]' + return `[${value.length} items]` + } + if (typeof value === 'object') { + const keys = Object.keys(value) + if (keys.length === 0) return '{}' + return `{${keys.length} fields}` + } + return String(value) + } + + // Format subblock ID to readable label + const formatSubBlockLabel = (id: string): string => { + return id + .replace(/([A-Z])/g, ' $1') + .replace(/[_-]/g, ' ') + .trim() + .split(' ') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' ') + } + + // Render a single block item with action icon and details + const renderBlockItem = ( change: BlockChange, type: 'add' | 'edit' | 'delete' ) => { @@ -700,74 +795,67 @@ function WorkflowEditSummary({ toolCall }: { toolCall: CopilotToolCall }) { const Icon = blockConfig?.icon const bgColor = blockConfig?.bgColor || '#6B7280' - const symbols = { + const actionIcons = { add: { symbol: '+', color: 'text-[#22c55e]' }, - edit: { symbol: '•', color: 'text-[#f97316]' }, + edit: { symbol: '~', color: 'text-[#f97316]' }, delete: { symbol: '-', color: 'text-[#ef4444]' }, } - const { symbol, color } = symbols[type] + const { symbol, color } = actionIcons[type] + + const subBlocksToShow = type === 'add' ? change.subBlocks : type === 'edit' ? change.changedSubBlocks : undefined return (
- {symbol} - {/* Toolbar-style icon: colored square with white icon */} -
- {Icon && } + {/* Block header */} +
+
+ {/* Toolbar-style icon: colored square with white icon */} +
+ {Icon && } +
+ + {change.blockName} + +
+ {/* Action icon in top right */} + {symbol}
- - {change.blockName} - + + {/* Subblock details */} + {subBlocksToShow && subBlocksToShow.length > 0 && ( +
+ {subBlocksToShow.map((sb) => ( +
+ + {formatSubBlockLabel(sb.id)}: + + + {formatSubBlockValue(sb.value)} + +
+ ))} +
+ )}
) } return ( -
- {/* Header row - always visible */} - - - {/* Expanded block list */} - {isExpanded && ( -
- {addedBlocks.map((change) => renderBlockRow(change, 'add'))} - {editedBlocks.map((change) => renderBlockRow(change, 'edit'))} - {deletedBlocks.map((change) => renderBlockRow(change, 'delete'))} -
- )} +
+ {addedBlocks.map((change) => renderBlockItem(change, 'add'))} + {editedBlocks.map((change) => renderBlockItem(change, 'edit'))} + {deletedBlocks.map((change) => renderBlockItem(change, 'delete'))}
) } From e95b6135ac7f0b2bca2e6ba97cca9cf1596677d9 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 8 Jan 2026 12:57:54 -0800 Subject: [PATCH 13/39] Options --- .../copilot-message/copilot-message.tsx | 120 ++++---- .../components/tool-call/tool-call.tsx | 268 +++++++++++++++++- .../panel/components/copilot/copilot.tsx | 1 + 3 files changed, 314 insertions(+), 75 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx index 3e1cd81df2..4311d09188 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx @@ -1,9 +1,9 @@ 'use client' -import { type FC, memo, useMemo, useState } from 'react' -import { Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from 'lucide-react' +import { type FC, memo, useCallback, useMemo, useState } from 'react' +import { RotateCcw } from 'lucide-react' import { Button } from '@/components/emcn' -import { ToolCall } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components' +import { OptionsSelector, parseSpecialTags, ToolCall } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components' import { FileAttachmentDisplay, SmoothStreamingText, @@ -15,8 +15,6 @@ import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId import { useCheckpointManagement, useMessageEditing, - useMessageFeedback, - useSuccessTimers, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks' import { UserInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input' import { useCopilotStore } from '@/stores/panel/copilot/store' @@ -40,6 +38,8 @@ interface CopilotMessageProps { onEditModeChange?: (isEditing: boolean, cancelCallback?: () => void) => void /** Callback when revert mode changes */ onRevertModeChange?: (isReverting: boolean) => void + /** Whether this is the last message in the conversation */ + isLastMessage?: boolean } /** @@ -59,6 +59,7 @@ const CopilotMessage: FC = memo( checkpointCount = 0, onEditModeChange, onRevertModeChange, + isLastMessage = false, }) => { const isUser = message.role === 'user' const isAssistant = message.role === 'assistant' @@ -88,22 +89,6 @@ const CopilotMessage: FC = memo( // UI state const [isHoveringMessage, setIsHoveringMessage] = useState(false) - // Success timers hook - const { - showCopySuccess, - showUpvoteSuccess, - showDownvoteSuccess, - handleCopy, - setShowUpvoteSuccess, - setShowDownvoteSuccess, - } = useSuccessTimers() - - // Message feedback hook - const { handleUpvote, handleDownvote } = useMessageFeedback(message, messages, { - setShowUpvoteSuccess, - setShowDownvoteSuccess, - }) - // Checkpoint management hook const { showRestoreConfirmation, @@ -153,14 +138,6 @@ const CopilotMessage: FC = memo( pendingEditRef, }) - /** - * Handles copying message content to clipboard - * Uses the success timer hook to show feedback - */ - const handleCopyContent = () => { - handleCopy(message.content) - } - // Get clean text content with double newline parsing const cleanTextContent = useMemo(() => { if (!message.content) return '' @@ -169,6 +146,24 @@ const CopilotMessage: FC = memo( return message.content.replace(/\n{3,}/g, '\n\n') }, [message.content]) + // Parse special tags from message content (options, plan) + const parsedTags = useMemo(() => { + if (!message.content || isUser) return null + return parseSpecialTags(message.content) + }, [message.content, isUser]) + + // Get sendMessage from store for continuation actions + const sendMessage = useCopilotStore((s) => s.sendMessage) + + // Handler for option selection + const handleOptionSelect = useCallback( + (_optionKey: string, optionText: string) => { + // Send the option text as a message + sendMessage(optionText) + }, + [sendMessage] + ) + // Memoize content blocks to avoid re-rendering unchanged blocks const memoizedContentBlocks = useMemo(() => { if (!message.contentBlocks || message.contentBlocks.length === 0) { @@ -179,8 +174,12 @@ const CopilotMessage: FC = memo( if (block.type === 'text') { const isLastTextBlock = index === message.contentBlocks!.length - 1 && block.type === 'text' - // Clean content for this text block - const cleanBlockContent = block.content.replace(/\n{3,}/g, '\n\n') + // Clean content for this text block - strip special tags and excessive newlines + const parsed = parseSpecialTags(block.content) + const cleanBlockContent = parsed.cleanContent.replace(/\n{3,}/g, '\n\n') + + // Skip if no content after stripping tags + if (!cleanBlockContent.trim()) return null // Use smooth streaming for the last text block if we're streaming const shouldUseSmoothing = isStreaming && isLastTextBlock @@ -467,47 +466,6 @@ const CopilotMessage: FC = memo(
)} - {/* Action buttons for completed messages */} - {!isStreaming && cleanTextContent && ( -
- - - -
- )} {/* Citations if available */} {message.citations && message.citations.length > 0 && ( @@ -528,6 +486,19 @@ const CopilotMessage: FC = memo(
)} + + {/* Options selector when agent presents choices */} + {!isStreaming && + parsedTags?.options && + Object.keys(parsedTags.options).length > 0 && ( + + )} + ) @@ -565,6 +536,11 @@ const CopilotMessage: FC = memo( return false } + // If isLastMessage changed, re-render (for options visibility) + if (prevProps.isLastMessage !== nextProps.isLastMessage) { + return false + } + // For streaming messages, check if content actually changed if (nextProps.isStreaming) { const prevBlocks = prevMessage.contentBlocks || [] 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 0c4d2fb333..c0fdeb14c0 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 @@ -1,8 +1,8 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' -import { ChevronDown, ChevronUp } from 'lucide-react' +import { Check, ChevronDown, ChevronUp } from 'lucide-react' import { Button, Code } from '@/components/emcn' import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool' import { getClientTool } from '@/lib/copilot/tools/client/manager' @@ -12,6 +12,250 @@ import { CLASS_TOOL_METADATA, useCopilotStore } from '@/stores/panel/copilot/sto import type { CopilotToolCall, SubAgentContentBlock } from '@/stores/panel/copilot/types' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer' + +/** + * Parse special tags from content + */ +/** + * Plan step can be either a string or an object with title and plan + */ +type PlanStep = string | { title: string; plan?: string } + +/** + * Option can be either a string or an object with title and description + */ +type OptionItem = string | { title: string; description?: string } + +interface ParsedTags { + plan?: Record + options?: Record + cleanContent: string +} + +/** + * Parse and tags from content + */ +export function parseSpecialTags(content: string): ParsedTags { + const result: ParsedTags = { cleanContent: content } + + // Parse tag + const planMatch = content.match(/([\s\S]*?)<\/plan>/i) + if (planMatch) { + try { + result.plan = JSON.parse(planMatch[1]) + result.cleanContent = result.cleanContent.replace(planMatch[0], '').trim() + } catch { + // Invalid JSON, ignore + } + } + + // Parse tag + const optionsMatch = content.match(/([\s\S]*?)<\/options>/i) + if (optionsMatch) { + try { + result.options = JSON.parse(optionsMatch[1]) + result.cleanContent = result.cleanContent.replace(optionsMatch[0], '').trim() + } catch { + // Invalid JSON, ignore + } + } + + // Strip any incomplete/partial special tags that are still streaming + // This handles cases like "{"1": "op..." or "{..." during streaming + // Matches: followed by any content until end of string (no closing tag yet) + const incompleteTagPattern = /<(plan|options)>[\s\S]*$/i + result.cleanContent = result.cleanContent.replace(incompleteTagPattern, '').trim() + + // Also strip partial opening tags like " }) { + const sortedSteps = useMemo(() => { + return Object.entries(steps) + .sort(([a], [b]) => { + const numA = parseInt(a, 10) + const numB = parseInt(b, 10) + if (!isNaN(numA) && !isNaN(numB)) return numA - numB + return a.localeCompare(b) + }) + .map(([num, step]) => { + // Extract title from step - handle both string and object formats + const title = typeof step === 'string' ? step : step.title + return [num, title] as const + }) + }, [steps]) + + if (sortedSteps.length === 0) return null + + return ( +
+
+ + Workflow Plan + +
+
+ {sortedSteps.map(([num, title]) => ( +
+
+ {num} +
+
+ +
+
+ ))} +
+
+ ) +} + +/** + * OptionsSelector component renders selectable options from the agent + * Supports keyboard navigation (arrow up/down, enter) and click selection + * After selection, shows the chosen option highlighted and others struck through + */ +export function OptionsSelector({ + options, + onSelect, + disabled = false, + enableKeyboardNav = false, +}: { + options: Record + onSelect: (optionKey: string, optionText: string) => void + disabled?: boolean + /** Only enable keyboard navigation for the active options (last message) */ + enableKeyboardNav?: boolean +}) { + const sortedOptions = useMemo(() => { + return Object.entries(options) + .sort(([a], [b]) => { + const numA = parseInt(a, 10) + const numB = parseInt(b, 10) + if (!isNaN(numA) && !isNaN(numB)) return numA - numB + return a.localeCompare(b) + }) + .map(([key, option]) => { + const title = typeof option === 'string' ? option : option.title + const description = typeof option === 'string' ? undefined : option.description + return { key, title, description } + }) + }, [options]) + + const [hoveredIndex, setHoveredIndex] = useState(0) + const [chosenKey, setChosenKey] = useState(null) + const containerRef = useRef(null) + + const isLocked = chosenKey !== null + + // Handle keyboard navigation - only for the active options selector + useEffect(() => { + if (disabled || !enableKeyboardNav || isLocked) return + + const handleKeyDown = (e: KeyboardEvent) => { + // Only handle if the container or document body is focused (not when typing in input) + const activeElement = document.activeElement + const isInputFocused = + activeElement?.tagName === 'INPUT' || + activeElement?.tagName === 'TEXTAREA' || + activeElement?.getAttribute('contenteditable') === 'true' + + if (isInputFocused) return + + if (e.key === 'ArrowDown') { + e.preventDefault() + setHoveredIndex((prev) => Math.min(prev + 1, sortedOptions.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setHoveredIndex((prev) => Math.max(prev - 1, 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + const selected = sortedOptions[hoveredIndex] + if (selected) { + setChosenKey(selected.key) + onSelect(selected.key, selected.title) + } + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [disabled, enableKeyboardNav, isLocked, sortedOptions, hoveredIndex, onSelect]) + + if (sortedOptions.length === 0) return null + + return ( +
+
+ {sortedOptions.map((option, index) => { + const isHovered = index === hoveredIndex && !isLocked + const isChosen = option.key === chosenKey + const isRejected = isLocked && !isChosen + + return ( +
{ + if (!disabled && !isLocked) { + setChosenKey(option.key) + onSelect(option.key, option.title) + } + }} + onMouseEnter={() => { + if (!isLocked) setHoveredIndex(index) + }} + className={`flex items-start gap-2.5 px-2.5 py-2 transition-colors ${ + isLocked + ? isChosen + ? 'bg-[var(--surface-3)]' + : 'bg-transparent' + : isHovered + ? 'bg-[var(--surface-3)] cursor-pointer' + : 'hover:bg-[var(--surface-2)] cursor-pointer' + } ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${isLocked ? 'cursor-default' : ''}`} + > + {/* Option number */} +
+ {option.key}. +
+ + {/* Option content */} +
+ +
+
+ ) + })} +
+
+ ) +} interface ToolCallProps { toolCall?: CopilotToolCall @@ -556,12 +800,16 @@ function SubAgentContent({ {blocks.map((block, index) => { if (block.type === 'subagent_text' && block.content) { const isLastBlock = index === blocks.length - 1 + // Strip special tags from display (they're rendered separately) + const parsed = parseSpecialTags(block.content) + const displayContent = parsed.cleanContent + if (!displayContent) return null return (
-                  {block.content}
+                  {displayContent}
                   {isStreaming && isLastBlock && (
                     
                   )}
@@ -592,6 +840,20 @@ function SubAgentContent({
             toolCall={block.toolCall!}
           />
         ))}
+
+      {/* Render PlanSteps for plan subagent when content contains  tag */}
+      {toolName === 'plan' && (() => {
+        // Combine all text content from blocks
+        const allText = blocks
+          .filter((b) => b.type === 'subagent_text' && b.content)
+          .map((b) => b.content)
+          .join('')
+        const parsed = parseSpecialTags(allText)
+        if (parsed.plan && Object.keys(parsed.plan).length > 0) {
+          return 
+        }
+        return null
+      })()}
     
   )
 }
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 bbef0b876e..edc6f7da3f 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
@@ -563,6 +563,7 @@ export const Copilot = forwardRef(({ panelWidth }, ref
                             onRevertModeChange={(isReverting) =>
                               handleRevertModeChange(message.id, isReverting)
                             }
+                            isLastMessage={index === messages.length - 1}
                           />
                         )
                       })}

From 96dc2b7afd10c5a9d6372808bc9b984ffdf176f1 Mon Sep 17 00:00:00 2001
From: Siddharth Ganesan 
Date: Thu, 8 Jan 2026 12:58:22 -0800
Subject: [PATCH 14/39] Lint

---
 .../components/thinking-block.tsx             |   5 +-
 .../copilot-message/copilot-message.tsx       |  26 +-
 .../queued-messages/queued-messages.tsx       |  11 +-
 .../components/tool-call/tool-call.tsx        | 276 ++++++++++--------
 apps/sim/lib/copilot/registry.ts              |   4 +-
 .../tools/client/blocks/get-block-options.ts  |   6 +-
 .../lib/copilot/tools/client/other/auth.ts    |   1 -
 .../copilot/tools/client/other/custom-tool.ts |   1 -
 .../lib/copilot/tools/client/other/debug.ts   |   1 -
 .../lib/copilot/tools/client/other/deploy.ts  |   1 -
 .../lib/copilot/tools/client/other/edit.ts    |   1 -
 .../lib/copilot/tools/client/other/info.ts    |   1 -
 .../copilot/tools/client/other/knowledge.ts   |   1 -
 .../copilot/tools/client/other/research.ts    |   1 -
 .../lib/copilot/tools/client/other/test.ts    |   1 -
 .../lib/copilot/tools/client/other/tour.ts    |   1 -
 .../copilot/tools/client/other/workflow.ts    |   1 -
 apps/sim/stores/panel/copilot/store.ts        |  56 ++--
 18 files changed, 227 insertions(+), 168 deletions(-)

diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx
index 7b09fa09ec..1238af807e 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx
@@ -171,7 +171,10 @@ export function ThinkingBlock({
         
         {hasContent && (
           
) @@ -228,6 +229,7 @@ export function ThinkingBlock({ isExpanded ? 'mt-1 max-h-[200px] opacity-100' : 'max-h-0 opacity-0' )} > + {/* Use markdown renderer for completed content */}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx index 2a9b626eda..0ab04f19ef 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx @@ -151,28 +151,13 @@ const CopilotMessage: FC = memo( }, [message.content]) // Parse special tags from message content (options, plan) - // During streaming, also check content blocks since message.content may not be updated yet + // Only parse after streaming is complete to avoid affecting streaming smoothness const parsedTags = useMemo(() => { - if (isUser) return null - - // Try message.content first - if (message.content) { - const parsed = parseSpecialTags(message.content) - if (parsed.options) return parsed - } - - // During streaming, check content blocks for options - if (message.contentBlocks && message.contentBlocks.length > 0) { - for (const block of message.contentBlocks) { - if (block.type === 'text' && block.content) { - const parsed = parseSpecialTags(block.content) - if (parsed.options) return parsed - } - } - } + if (isUser || isStreaming) return null + // Only parse when not streaming - options should appear after message completes return message.content ? parseSpecialTags(message.content) : null - }, [message.content, message.contentBlocks, isUser]) + }, [message.content, isUser, isStreaming]) // Get sendMessage from store for continuation actions const sendMessage = useCopilotStore((s) => s.sendMessage) @@ -196,12 +181,14 @@ const CopilotMessage: FC = memo( if (block.type === 'text') { const isLastTextBlock = index === message.contentBlocks!.length - 1 && block.type === 'text' - // Clean content for this text block - strip special tags and excessive newlines - const parsed = parseSpecialTags(block.content) - const cleanBlockContent = parsed.cleanContent.replace(/\n{3,}/g, '\n\n') + // During streaming, use raw content for smooth performance + // Only strip special tags after streaming completes + const cleanBlockContent = isStreaming + ? block.content.replace(/\n{3,}/g, '\n\n') + : parseSpecialTags(block.content).cleanContent.replace(/\n{3,}/g, '\n\n') - // Skip if no content after stripping tags - if (!cleanBlockContent.trim()) return null + // Skip if no content after stripping tags (only when not streaming) + if (!isStreaming && !cleanBlockContent.trim()) return null // Use smooth streaming for the last text block if we're streaming const shouldUseSmoothing = isStreaming && isLastTextBlock @@ -508,14 +495,14 @@ const CopilotMessage: FC = memo( )} - {/* Options selector when agent presents choices - streams in but disabled until complete */} - {parsedTags?.options && Object.keys(parsedTags.options).length > 0 && ( + {/* Options selector when agent presents choices - only shown after streaming completes */} + {!isStreaming && parsedTags?.options && Object.keys(parsedTags.options).length > 0 && ( )} From bd35dda8fac684f92e4726b5a9f23a3fedff0f57 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 9 Jan 2026 16:11:47 -0800 Subject: [PATCH 26/39] Fix thinking scroll --- .../components/thinking-block.tsx | 70 ++++++++++++++++-- .../components/tool-call/tool-call.tsx | 71 +++++++++++++++++-- 2 files changed, 127 insertions(+), 14 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx index 501907bd54..987b692aef 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx @@ -54,9 +54,12 @@ export function ThinkingBlock({ }: ThinkingBlockProps) { const [isExpanded, setIsExpanded] = useState(false) const [duration, setDuration] = useState(0) + const [userHasScrolledAway, setUserHasScrolledAway] = useState(false) const userCollapsedRef = useRef(false) const scrollContainerRef = useRef(null) const startTimeRef = useRef(Date.now()) + const lastScrollTopRef = useRef(0) + const programmaticScrollRef = useRef(false) /** * Auto-expands block when streaming with content @@ -67,6 +70,7 @@ export function ThinkingBlock({ if (!isStreaming || hasFollowingContent) { setIsExpanded(false) userCollapsedRef.current = false + setUserHasScrolledAway(false) return } @@ -80,6 +84,7 @@ export function ThinkingBlock({ if (isStreaming && !hasFollowingContent) { startTimeRef.current = Date.now() setDuration(0) + setUserHasScrolledAway(false) } }, [isStreaming, hasFollowingContent]) @@ -95,22 +100,65 @@ export function ThinkingBlock({ return () => clearInterval(interval) }, [isStreaming, hasFollowingContent]) - // Auto-scroll to bottom during streaming using interval (same as copilot chat) + // Handle scroll events to detect user scrolling away useEffect(() => { - if (!isStreaming || !isExpanded) return + const container = scrollContainerRef.current + if (!container || !isExpanded) return + + const handleScroll = () => { + if (programmaticScrollRef.current) return + + const { scrollTop, scrollHeight, clientHeight } = container + const distanceFromBottom = scrollHeight - scrollTop - clientHeight + const isNearBottom = distanceFromBottom <= 20 + + const delta = scrollTop - lastScrollTopRef.current + const movedUp = delta < -2 + + if (movedUp && !isNearBottom) { + setUserHasScrolledAway(true) + } + + // Re-stick if user scrolls back to bottom + if (userHasScrolledAway && isNearBottom) { + setUserHasScrolledAway(false) + } + + lastScrollTopRef.current = scrollTop + } + + container.addEventListener('scroll', handleScroll, { passive: true }) + lastScrollTopRef.current = container.scrollTop + + return () => container.removeEventListener('scroll', handleScroll) + }, [isExpanded, userHasScrolledAway]) + + // Smart auto-scroll: only scroll if user hasn't scrolled away + useEffect(() => { + if (!isStreaming || !isExpanded || userHasScrolledAway) return const intervalId = window.setInterval(() => { const container = scrollContainerRef.current if (!container) return - container.scrollTo({ - top: container.scrollHeight, - behavior: 'smooth', - }) + const { scrollTop, scrollHeight, clientHeight } = container + const distanceFromBottom = scrollHeight - scrollTop - clientHeight + const isNearBottom = distanceFromBottom <= 50 + + if (isNearBottom) { + programmaticScrollRef.current = true + container.scrollTo({ + top: container.scrollHeight, + behavior: 'smooth', + }) + window.setTimeout(() => { + programmaticScrollRef.current = false + }, 150) + } }, SCROLL_INTERVAL) return () => window.clearInterval(intervalId) - }, [isStreaming, isExpanded]) + }, [isStreaming, isExpanded, userHasScrolledAway]) /** * Formats duration in milliseconds to seconds @@ -137,6 +185,14 @@ export function ThinkingBlock({ if (!isThinkingDone) { return (
+ {/* Define shimmer keyframes */} +
)} - {/* Options selector when agent presents choices - only shown after streaming completes */} - {!isStreaming && parsedTags?.options && Object.keys(parsedTags.options).length > 0 && ( + {/* Options selector when agent presents choices - streams in but disabled until complete */} + {parsedTags?.options && Object.keys(parsedTags.options).length > 0 && ( )} 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 3226edcc1a..8dd9f72378 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 @@ -1943,13 +1943,23 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: // Get current mode from store to determine if we should render integration tools const mode = useCopilotStore.getState().mode + // Check if this is a completed/historical tool call (not pending/executing) + // Use string comparison to handle both enum values and string values from DB + const stateStr = String(toolCall.state) + const isCompletedToolCall = + stateStr === 'success' || + stateStr === 'error' || + stateStr === 'rejected' || + stateStr === 'aborted' + // Allow rendering if: // 1. Tool is in CLASS_TOOL_METADATA (client tools), OR - // 2. We're in build mode (integration tools are executed server-side) + // 2. We're in build mode (integration tools are executed server-side), OR + // 3. Tool call is already completed (historical - should always render) const isClientTool = !!CLASS_TOOL_METADATA[toolCall.name] const isIntegrationToolInBuildMode = mode === 'build' && !isClientTool - if (!isClientTool && !isIntegrationToolInBuildMode) { + if (!isClientTool && !isIntegrationToolInBuildMode && !isCompletedToolCall) { return null } // Check if tool has params table config (meaning it's expandable) diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index b157fde7b0..438380f6ad 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -386,122 +386,62 @@ function abortAllInProgressTools(set: any, get: () => CopilotStore) { } // Normalize loaded messages so assistant messages render correctly from DB +/** + * Loads messages from DB for UI rendering. + * Messages are stored exactly as they render, so we just need to: + * 1. Register client tool instances for any tool calls + * 2. Return the messages as-is + */ function normalizeMessagesForUI(messages: CopilotMessage[]): CopilotMessage[] { try { - return messages.map((message) => { - if (message.role !== 'assistant') { - // For user messages (and others), restore contexts from a saved contexts block - if (Array.isArray(message.contentBlocks) && message.contentBlocks.length > 0) { - const ctxBlock = (message.contentBlocks as any[]).find((b: any) => b?.type === 'contexts') - if (ctxBlock && Array.isArray((ctxBlock as any).contexts)) { - return { - ...message, - contexts: (ctxBlock as any).contexts, - } + // Log what we're loading + for (const message of messages) { + if (message.role === 'assistant') { + logger.info('[normalizeMessagesForUI] Loading assistant message', { + id: message.id, + hasContent: !!(message.content && message.content.trim()), + contentBlockCount: message.contentBlocks?.length || 0, + contentBlockTypes: (message.contentBlocks as any[])?.map(b => b?.type) || [], + }) + } + } + + // Register client tool instances for all tool calls so they can be looked up + for (const message of messages) { + if (message.contentBlocks) { + for (const block of message.contentBlocks as any[]) { + if (block?.type === 'tool_call' && block.toolCall) { + registerToolCallInstances(block.toolCall) } } - return message } + } + // Return messages as-is - they're already in the correct format for rendering + return messages + } catch { + return messages + } +} - // Use existing contentBlocks ordering if present; otherwise only render text content - const blocks: any[] = Array.isArray(message.contentBlocks) - ? (message.contentBlocks as any[]).map((b: any) => { - if (b?.type === 'tool_call' && b.toolCall) { - // Ensure client tool instance is registered for this tool call - ensureClientToolInstance(b.toolCall?.name, b.toolCall?.id) +/** + * Recursively registers client tool instances for a tool call and its nested subagent tool calls. + */ +function registerToolCallInstances(toolCall: any): void { + if (!toolCall?.id) return + ensureClientToolInstance(toolCall.name, toolCall.id) - return { - ...b, - toolCall: { - ...b.toolCall, - state: - isRejectedState(b.toolCall?.state) || - isReviewState(b.toolCall?.state) || - isBackgroundState(b.toolCall?.state) || - b.toolCall?.state === ClientToolCallState.success || - b.toolCall?.state === ClientToolCallState.error || - b.toolCall?.state === ClientToolCallState.aborted - ? b.toolCall.state - : ClientToolCallState.rejected, - display: resolveToolDisplay( - b.toolCall?.name, - (isRejectedState(b.toolCall?.state) || - isReviewState(b.toolCall?.state) || - isBackgroundState(b.toolCall?.state) || - b.toolCall?.state === ClientToolCallState.success || - b.toolCall?.state === ClientToolCallState.error || - b.toolCall?.state === ClientToolCallState.aborted - ? (b.toolCall?.state as any) - : ClientToolCallState.rejected) as any, - b.toolCall?.id, - b.toolCall?.params - ), - }, - } - } - if (b?.type === TEXT_BLOCK_TYPE && typeof b.content === 'string') { - return { - ...b, - content: stripTodoTags(b.content), - } - } - return b - }) - : [] - - // Prepare toolCalls with display for non-block UI components, but do not fabricate blocks - const updatedToolCalls = Array.isArray((message as any).toolCalls) - ? (message as any).toolCalls.map((tc: any) => { - // Ensure client tool instance is registered for this tool call - ensureClientToolInstance(tc?.name, tc?.id) - - return { - ...tc, - state: - isRejectedState(tc?.state) || - isReviewState(tc?.state) || - isBackgroundState(tc?.state) || - tc?.state === ClientToolCallState.success || - tc?.state === ClientToolCallState.error || - tc?.state === ClientToolCallState.aborted - ? tc.state - : ClientToolCallState.rejected, - display: resolveToolDisplay( - tc?.name, - (isRejectedState(tc?.state) || - isReviewState(tc?.state) || - isBackgroundState(tc?.state) || - tc?.state === ClientToolCallState.success || - tc?.state === ClientToolCallState.error || - tc?.state === ClientToolCallState.aborted - ? (tc?.state as any) - : ClientToolCallState.rejected) as any, - tc?.id, - tc?.params - ), - } - }) - : (message as any).toolCalls - - const sanitizedContent = stripTodoTags(message.content || '') - - return { - ...message, - content: sanitizedContent, - ...(updatedToolCalls && { toolCalls: updatedToolCalls }), - ...(blocks.length > 0 - ? { contentBlocks: blocks } - : sanitizedContent.trim() - ? { - contentBlocks: [ - { type: TEXT_BLOCK_TYPE, content: sanitizedContent, timestamp: Date.now() }, - ], - } - : {}), + // Register nested subagent tool calls + if (Array.isArray(toolCall.subAgentBlocks)) { + for (const block of toolCall.subAgentBlocks) { + if (block?.type === 'subagent_tool_call' && block.toolCall) { + registerToolCallInstances(block.toolCall) } - }) - } catch { - return messages + } + } + if (Array.isArray(toolCall.subAgentToolCalls)) { + for (const subTc of toolCall.subAgentToolCalls) { + registerToolCallInstances(subTc) + } } } @@ -626,62 +566,154 @@ function stripTodoTags(text: string): string { .replace(/\n{2,}/g, '\n') } -function validateMessagesForLLM(messages: CopilotMessage[]): any[] { - return messages +/** + * Deep clones an object using JSON serialization. + * This ensures we strip any non-serializable data (functions, circular refs). + */ +function deepClone(obj: T): T { + try { + return JSON.parse(JSON.stringify(obj)) + } catch { + return obj + } +} + +/** + * Serializes messages for database storage. + * Deep clones all fields to ensure proper JSON serialization. + * This ensures they render identically when loaded back. + */ +function serializeMessagesForDB(messages: CopilotMessage[]): any[] { + const result = messages .map((msg) => { - // Build content from blocks if assistant content is empty (exclude thinking) - let content = msg.content || '' - if (msg.role === 'assistant' && !content.trim() && msg.contentBlocks?.length) { - content = msg.contentBlocks - .filter((b: any) => b?.type === 'text') - .map((b: any) => String(b.content || '')) - .join('') - .trim() - } - - // Strip thinking, design_workflow, and todo tags from content - if (content) { - content = stripTodoTags( - content - .replace(/[\s\S]*?<\/thinking>/g, '') - .replace(/[\s\S]*?<\/design_workflow>/g, '') - ).trim() - } - - return { + // Deep clone the entire message to ensure all nested data is serializable + const serialized: any = { id: msg.id, role: msg.role, - content, + content: msg.content || '', timestamp: msg.timestamp, - ...(Array.isArray((msg as any).toolCalls) && - (msg as any).toolCalls.length > 0 && { - toolCalls: (msg as any).toolCalls, - }), - ...(Array.isArray(msg.contentBlocks) && - msg.contentBlocks.length > 0 && { - // Persist full contentBlocks including thinking so history can render it - contentBlocks: msg.contentBlocks, - }), - ...(msg.fileAttachments && - msg.fileAttachments.length > 0 && { - fileAttachments: msg.fileAttachments, - }), - ...((msg as any).contexts && - Array.isArray((msg as any).contexts) && { - contexts: (msg as any).contexts, - }), } + + // Deep clone contentBlocks (the main rendering data) + if (Array.isArray(msg.contentBlocks) && msg.contentBlocks.length > 0) { + serialized.contentBlocks = deepClone(msg.contentBlocks) + } + + // Deep clone toolCalls + if (Array.isArray((msg as any).toolCalls) && (msg as any).toolCalls.length > 0) { + serialized.toolCalls = deepClone((msg as any).toolCalls) + } + + // Deep clone file attachments + if (Array.isArray(msg.fileAttachments) && msg.fileAttachments.length > 0) { + serialized.fileAttachments = deepClone(msg.fileAttachments) + } + + // Deep clone contexts + if (Array.isArray((msg as any).contexts) && (msg as any).contexts.length > 0) { + serialized.contexts = deepClone((msg as any).contexts) + } + + // Deep clone citations + if (Array.isArray(msg.citations) && msg.citations.length > 0) { + serialized.citations = deepClone(msg.citations) + } + + // Copy error type + if (msg.errorType) { + serialized.errorType = msg.errorType + } + + return serialized }) - .filter((m) => { - if (m.role === 'assistant') { - const hasText = typeof m.content === 'string' && m.content.trim().length > 0 - const hasTools = Array.isArray((m as any).toolCalls) && (m as any).toolCalls.length > 0 - const hasBlocks = - Array.isArray((m as any).contentBlocks) && (m as any).contentBlocks.length > 0 - return hasText || hasTools || hasBlocks + .filter((msg) => { + // Filter out empty assistant messages + if (msg.role === 'assistant') { + const hasContent = typeof msg.content === 'string' && msg.content.trim().length > 0 + const hasTools = Array.isArray(msg.toolCalls) && msg.toolCalls.length > 0 + const hasBlocks = Array.isArray(msg.contentBlocks) && msg.contentBlocks.length > 0 + return hasContent || hasTools || hasBlocks } return true }) + + // Log what we're serializing + for (const msg of messages) { + if (msg.role === 'assistant') { + logger.info('[serializeMessagesForDB] Input assistant message', { + id: msg.id, + hasContent: !!(msg.content && msg.content.trim()), + contentBlockCount: msg.contentBlocks?.length || 0, + contentBlockTypes: (msg.contentBlocks as any[])?.map(b => b?.type) || [], + }) + } + } + + logger.info('[serializeMessagesForDB] Serialized messages', { + inputCount: messages.length, + outputCount: result.length, + sample: result.length > 0 ? { + role: result[result.length - 1].role, + hasContent: !!result[result.length - 1].content, + contentBlockCount: result[result.length - 1].contentBlocks?.length || 0, + toolCallCount: result[result.length - 1].toolCalls?.length || 0, + } : null + }) + + return result +} + +/** + * @deprecated Use serializeMessagesForDB instead. + */ +function validateMessagesForLLM(messages: CopilotMessage[]): any[] { + return serializeMessagesForDB(messages) +} + +/** + * Extracts all tool calls from a toolCall object, including nested subAgentBlocks. + * Adds them to the provided map. + */ +function extractToolCallsRecursively( + toolCall: CopilotToolCall, + map: Record +): void { + if (!toolCall?.id) return + map[toolCall.id] = toolCall + + // Extract nested tool calls from subAgentBlocks + if (Array.isArray(toolCall.subAgentBlocks)) { + for (const block of toolCall.subAgentBlocks) { + if (block?.type === 'subagent_tool_call' && block.toolCall?.id) { + extractToolCallsRecursively(block.toolCall, map) + } + } + } + + // Extract from subAgentToolCalls as well + if (Array.isArray(toolCall.subAgentToolCalls)) { + for (const subTc of toolCall.subAgentToolCalls) { + extractToolCallsRecursively(subTc, map) + } + } +} + +/** + * Builds a complete toolCallsById map from normalized messages. + * Extracts all tool calls including nested subagent tool calls. + */ +function buildToolCallsById(messages: CopilotMessage[]): Record { + const toolCallsById: Record = {} + for (const msg of messages) { + if (msg.contentBlocks) { + for (const block of msg.contentBlocks as any[]) { + if (block?.type === 'tool_call' && block.toolCall?.id) { + extractToolCallsRecursively(block.toolCall, toolCallsById) + } + } + } + } + return toolCallsById } // Streaming context and SSE parsing @@ -2089,9 +2121,13 @@ export const useCopilotStore = create()( const previousModel = get().selectedModel // Optimistically set selected chat and normalize messages for UI + const normalizedMessages = normalizeMessagesForUI(chat.messages || []) + const toolCallsById = buildToolCallsById(normalizedMessages) + set({ currentChat: chat, - messages: normalizeMessagesForUI(chat.messages || []), + messages: normalizedMessages, + toolCallsById, planTodos: [], showPlanTodos: false, streamingPlanContent: planArtifact, @@ -2130,18 +2166,7 @@ export const useCopilotStore = create()( const latestChat = data.chats.find((c: CopilotChat) => c.id === chat.id) if (latestChat) { const normalizedMessages = normalizeMessagesForUI(latestChat.messages || []) - - // Build toolCallsById map from all tool calls in normalized messages - const toolCallsById: Record = {} - for (const msg of normalizedMessages) { - if (msg.contentBlocks) { - for (const block of msg.contentBlocks as any[]) { - if (block?.type === 'tool_call' && block.toolCall?.id) { - toolCallsById[block.toolCall.id] = block.toolCall - } - } - } - } + const toolCallsById = buildToolCallsById(normalizedMessages) set({ currentChat: latestChat, @@ -2277,18 +2302,7 @@ export const useCopilotStore = create()( const refreshedConfig = updatedCurrentChat.config || {} const refreshedMode = refreshedConfig.mode || get().mode const refreshedModel = refreshedConfig.model || get().selectedModel - - // Build toolCallsById map from all tool calls in normalized messages - const toolCallsById: Record = {} - for (const msg of normalizedMessages) { - if (msg.contentBlocks) { - for (const block of msg.contentBlocks as any[]) { - if (block?.type === 'tool_call' && block.toolCall?.id) { - toolCallsById[block.toolCall.id] = block.toolCall - } - } - } - } + const toolCallsById = buildToolCallsById(normalizedMessages) set({ currentChat: updatedCurrentChat, @@ -2319,17 +2333,7 @@ export const useCopilotStore = create()( hasPlanArtifact: !!planArtifact, }) - // Build toolCallsById map from all tool calls in normalized messages - const toolCallsById: Record = {} - for (const msg of normalizedMessages) { - if (msg.contentBlocks) { - for (const block of msg.contentBlocks as any[]) { - if (block?.type === 'tool_call' && block.toolCall?.id) { - toolCallsById[block.toolCall.id] = block.toolCall - } - } - } - } + const toolCallsById = buildToolCallsById(normalizedMessages) set({ currentChat: mostRecentChat, @@ -3127,6 +3131,17 @@ export const useCopilotStore = create()( if (currentChat) { try { const currentMessages = get().messages + // Debug: Log what we're about to serialize + const lastMsg = currentMessages[currentMessages.length - 1] + if (lastMsg?.role === 'assistant') { + logger.info('[Stream Done] About to serialize - last message state', { + id: lastMsg.id, + contentLength: lastMsg.content?.length || 0, + hasContentBlocks: !!lastMsg.contentBlocks, + contentBlockCount: lastMsg.contentBlocks?.length || 0, + contentBlockTypes: (lastMsg.contentBlocks as any[])?.map(b => b?.type) || [], + }) + } const dbMessages = validateMessagesForLLM(currentMessages) const config = { mode, From 0ba5ec65f78e3fd74bc5807fd7d2f64333ea5388 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 9 Jan 2026 18:26:29 -0800 Subject: [PATCH 30/39] Diff view --- .../diff-controls/diff-controls.tsx | 71 ++++++++-------- apps/sim/stores/workflow-diff/store.ts | 81 +++++++++++-------- 2 files changed, 80 insertions(+), 72 deletions(-) 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 5664c769c2..d8424fbfa9 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 @@ -206,54 +206,47 @@ export const DiffControls = memo(function DiffControls() { } }, [activeWorkflowId, currentChat, messages, baselineWorkflow]) - const handleAccept = useCallback(async () => { + const handleAccept = useCallback(() => { logger.info('Accepting proposed changes with backup protection') + // Resolve target toolCallId for build/edit and update to terminal success state in the copilot store + // This happens synchronously first for instant UI feedback try { - // Create a checkpoint before applying changes so it appears under the triggering user message - await createCheckpoint().catch((error) => { - logger.warn('Failed to create checkpoint before accept:', error) - }) - - // Resolve target toolCallId for build/edit and update to terminal success state in the copilot store - try { - const { toolCallsById, messages } = useCopilotStore.getState() - let id: string | undefined - outer: for (let mi = messages.length - 1; mi >= 0; mi--) { - const m = messages[mi] - if (m.role !== 'assistant' || !m.contentBlocks) continue - const blocks = m.contentBlocks as any[] - for (let bi = blocks.length - 1; bi >= 0; bi--) { - const b = blocks[bi] - if (b?.type === 'tool_call') { - const tn = b.toolCall?.name - if (tn === 'edit_workflow') { - id = b.toolCall?.id - break outer - } + const { toolCallsById, messages } = useCopilotStore.getState() + let id: string | undefined + outer: for (let mi = messages.length - 1; mi >= 0; mi--) { + const m = messages[mi] + if (m.role !== 'assistant' || !m.contentBlocks) continue + const blocks = m.contentBlocks as any[] + for (let bi = blocks.length - 1; bi >= 0; bi--) { + const b = blocks[bi] + if (b?.type === 'tool_call') { + const tn = b.toolCall?.name + if (tn === 'edit_workflow') { + id = b.toolCall?.id + break outer } } } - if (!id) { - const candidates = Object.values(toolCallsById).filter((t) => t.name === 'edit_workflow') - id = candidates.length ? candidates[candidates.length - 1].id : undefined - } - if (id) updatePreviewToolCallState('accepted', id) - } catch {} + } + if (!id) { + const candidates = Object.values(toolCallsById).filter((t) => t.name === 'edit_workflow') + id = candidates.length ? candidates[candidates.length - 1].id : undefined + } + if (id) updatePreviewToolCallState('accepted', id) + } catch {} - // Accept changes without blocking the UI; errors will be logged by the store handler - acceptChanges().catch((error) => { - logger.error('Failed to accept changes (background):', error) - }) + // Accept changes without blocking the UI; errors will be logged by the store handler + acceptChanges().catch((error) => { + logger.error('Failed to accept changes (background):', error) + }) - logger.info('Accept triggered; UI will update optimistically') - } catch (error) { - logger.error('Failed to accept changes:', error) + // Create checkpoint in the background (fire-and-forget) so it doesn't block UI + createCheckpoint().catch((error) => { + logger.warn('Failed to create checkpoint after accept:', error) + }) - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' - logger.error('Workflow update failed:', errorMessage) - alert(`Failed to save workflow changes: ${errorMessage}`) - } + logger.info('Accept triggered; UI will update optimistically') }, [createCheckpoint, updatePreviewToolCallState, acceptChanges]) const handleReject = useCallback(() => { diff --git a/apps/sim/stores/workflow-diff/store.ts b/apps/sim/stores/workflow-diff/store.ts index 2fb8fe65b5..54ee8b69d9 100644 --- a/apps/sim/stores/workflow-diff/store.ts +++ b/apps/sim/stores/workflow-diff/store.ts @@ -433,6 +433,7 @@ export const useWorkflowDiffStore = create {}) } - const toolCallId = await findLatestEditWorkflowToolCallId() - if (toolCallId) { - try { - await getClientTool(toolCallId)?.handleAccept?.() - } catch (error) { - logger.warn('Failed to notify tool accept state', { error }) + findLatestEditWorkflowToolCallId().then((toolCallId) => { + if (toolCallId) { + getClientTool(toolCallId)?.handleAccept?.()?.catch?.((error: Error) => { + logger.warn('Failed to notify tool accept state', { error }) + }) } - } + }) }, rejectChanges: async () => { @@ -487,27 +487,26 @@ export const useWorkflowDiffStore = create { + logger.error('Failed to broadcast reject to other users:', error) + }) + + // Persist to database in background + persistWorkflowStateToServer(baselineWorkflowId, baselineWorkflow).catch((error) => { + logger.error('Failed to persist baseline workflow state:', error) + }) + if (_triggerMessageId) { fetch('/api/copilot/stats', { method: 'POST', @@ -534,16 +552,13 @@ export const useWorkflowDiffStore = create {}) } - const toolCallId = await findLatestEditWorkflowToolCallId() - if (toolCallId) { - try { - await getClientTool(toolCallId)?.handleReject?.() - } catch (error) { - logger.warn('Failed to notify tool reject state', { error }) + findLatestEditWorkflowToolCallId().then((toolCallId) => { + if (toolCallId) { + getClientTool(toolCallId)?.handleReject?.()?.catch?.((error: Error) => { + logger.warn('Failed to notify tool reject state', { error }) + }) } - } - - get().clearDiff({ restoreBaseline: false }) + }) }, reapplyDiffMarkers: () => { From 6639871c92dec5d6f753ed84f9eadbf1ea3f82d5 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 9 Jan 2026 18:26:42 -0800 Subject: [PATCH 31/39] Fix lint --- .../api/copilot/chat/update-messages/route.ts | 4 +- .../components/thinking-block.tsx | 4 +- .../copilot-message/copilot-message.tsx | 4 +- .../components/tool-call/tool-call.tsx | 50 ++++++++++++------- apps/sim/lib/copilot/registry.ts | 5 +- .../tools/client/base-subagent-tool.ts | 7 +-- .../copilot/tools/client/init-tool-configs.ts | 13 +++-- .../sim/lib/copilot/tools/client/ui-config.ts | 1 - .../workflow/create-workspace-mcp-server.ts | 1 - .../tools/client/workflow/deploy-api.ts | 1 - .../tools/client/workflow/deploy-chat.ts | 1 - .../tools/client/workflow/deploy-mcp.ts | 6 +-- .../workflow/list-workspace-mcp-servers.ts | 13 +++-- apps/sim/stores/panel/copilot/store.ts | 27 +++++----- apps/sim/stores/workflow-diff/store.ts | 16 +++--- 15 files changed, 86 insertions(+), 67 deletions(-) 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 f9e273dc91..adb040bcce 100644 --- a/apps/sim/app/api/copilot/chat/update-messages/route.ts +++ b/apps/sim/app/api/copilot/chat/update-messages/route.ts @@ -62,7 +62,7 @@ export async function POST(req: NextRequest) { } const body = await req.json() - + // Debug: Log what we received const lastMsg = body.messages?.[body.messages.length - 1] if (lastMsg?.role === 'assistant') { @@ -74,7 +74,7 @@ export async function POST(req: NextRequest) { lastMsgContentBlockTypes: lastMsg.contentBlocks?.map((b: any) => b?.type) || [], }) } - + const { chatId, messages, planArtifact, config } = UpdateMessagesSchema.parse(body) // Verify that the chat belongs to the user diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx index b5def33467..c92e01ba26 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx @@ -246,7 +246,7 @@ export function ThinkingBlock({ )} > {/* Render markdown during streaming with thinking text styling */} -
+
@@ -286,7 +286,7 @@ export function ThinkingBlock({ )} > {/* Use markdown renderer for completed content */} -
+
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx index d06f90b039..c51727d752 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx @@ -514,7 +514,9 @@ const CopilotMessage: FC = memo( options={parsedTags.options} onSelect={handleOptionSelect} disabled={isSendingMessage || isStreaming} - enableKeyboardNav={isLastMessage && !isStreaming && parsedTags.optionsComplete === true} + enableKeyboardNav={ + isLastMessage && !isStreaming && parsedTags.optionsComplete === true + } streaming={isStreaming || !parsedTags.optionsComplete} /> )} 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 8dd9f72378..4a67ef3809 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 @@ -10,10 +10,10 @@ import { getRegisteredTools } from '@/lib/copilot/tools/client/registry' // Initialize all tool UI configs import '@/lib/copilot/tools/client/init-tool-configs' import { - getToolUIConfig, - isSpecialTool as isSpecialToolFromConfig, getSubagentLabels as getSubagentLabelsFromConfig, + getToolUIConfig, hasInterrupt as hasInterruptFromConfig, + isSpecialTool as isSpecialToolFromConfig, } from '@/lib/copilot/tools/client/ui-config' import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer' import { SmoothStreamingText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming' @@ -357,19 +357,21 @@ export function OptionsSelector({ disabled && 'cursor-not-allowed opacity-50', streaming && 'pointer-events-none', isLocked && 'cursor-default', - isHovered && !streaming && 'is-hovered bg-[var(--surface-6)] dark:bg-[var(--surface-5)]' + isHovered && + !streaming && + 'is-hovered bg-[var(--surface-6)] dark:bg-[var(--surface-5)]' )} > @@ -1158,7 +1160,9 @@ function SubagentContentRenderer({ }, [isStreaming, shouldCollapse]) // Build segments: each segment is either text content or a tool call - const segments: Array<{ type: 'text'; content: string } | { type: 'tool'; block: SubAgentContentBlock }> = [] + const segments: Array< + { type: 'text'; content: string } | { type: 'tool'; block: SubAgentContentBlock } + > = [] let currentText = '' let allRawText = '' @@ -1916,20 +1920,21 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: 'workflow', ] const isSubagentTool = SUBAGENT_TOOLS.includes(toolCall.name) - + // For ALL subagent tools, don't show anything until we have blocks with content if (isSubagentTool) { // Check if we have any meaningful content in blocks - const hasContent = toolCall.subAgentBlocks && toolCall.subAgentBlocks.some(block => - (block.type === 'subagent_text' && block.content?.trim()) || - (block.type === 'subagent_tool_call' && block.toolCall) + const hasContent = toolCall.subAgentBlocks?.some( + (block) => + (block.type === 'subagent_text' && block.content?.trim()) || + (block.type === 'subagent_tool_call' && block.toolCall) ) - + if (!hasContent) { return null } } - + if (isSubagentTool && toolCall.subAgentBlocks && toolCall.subAgentBlocks.length > 0) { // Render subagent content using the dedicated component return ( @@ -1975,9 +1980,9 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: // Check UI config for secondary action const toolUIConfig = getToolUIConfig(toolCall.name) const secondaryAction = toolUIConfig?.secondaryAction - const showSecondaryAction = - secondaryAction && - secondaryAction.showInStates.includes(toolCall.state as ClientToolCallState) + const showSecondaryAction = secondaryAction?.showInStates.includes( + toolCall.state as ClientToolCallState + ) // Legacy fallbacks for tools that haven't migrated to UI config const showMoveToBackground = @@ -2396,7 +2401,10 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: )} {/* Render subagent content as thinking text */} {toolCall.subAgentBlocks && toolCall.subAgentBlocks.length > 0 && ( - + )}
) @@ -2455,7 +2463,10 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: )} {/* Render subagent content as thinking text */} {toolCall.subAgentBlocks && toolCall.subAgentBlocks.length > 0 && ( - + )} ) @@ -2564,7 +2575,10 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: {/* Render subagent content as thinking text */} {toolCall.subAgentBlocks && toolCall.subAgentBlocks.length > 0 && ( - + )} ) diff --git a/apps/sim/lib/copilot/registry.ts b/apps/sim/lib/copilot/registry.ts index 372edea35f..c1742ea7d5 100644 --- a/apps/sim/lib/copilot/registry.ts +++ b/apps/sim/lib/copilot/registry.ts @@ -121,7 +121,10 @@ export const ToolArgSchemas = { serverId: z .string() .describe('The MCP server ID to deploy to (get from list_workspace_mcp_servers)'), - workflowId: z.string().optional().describe('Optional workflow ID (defaults to active workflow)'), + workflowId: z + .string() + .optional() + .describe('Optional workflow ID (defaults to active workflow)'), toolName: z.string().optional().describe('Custom tool name (defaults to workflow name)'), toolDescription: z.string().optional().describe('Custom tool description'), parameterDescriptions: z diff --git a/apps/sim/lib/copilot/tools/client/base-subagent-tool.ts b/apps/sim/lib/copilot/tools/client/base-subagent-tool.ts index 277fcafb09..7a843dd882 100644 --- a/apps/sim/lib/copilot/tools/client/base-subagent-tool.ts +++ b/apps/sim/lib/copilot/tools/client/base-subagent-tool.ts @@ -8,11 +8,7 @@ * Examples: edit, plan, debug, evaluate, research, etc. */ import type { LucideIcon } from 'lucide-react' -import { - BaseClientTool, - type BaseClientToolMetadata, - ClientToolCallState, -} from './base-tool' +import { BaseClientTool, type BaseClientToolMetadata, ClientToolCallState } from './base-tool' import type { SubagentConfig, ToolUIConfig } from './ui-config' import { registerToolUIConfig } from './ui-config' @@ -122,4 +118,3 @@ export function createSubagentToolClass(config: SubagentToolConfig) { } } } - diff --git a/apps/sim/lib/copilot/tools/client/init-tool-configs.ts b/apps/sim/lib/copilot/tools/client/init-tool-configs.ts index be835835c1..821e5ec8d6 100644 --- a/apps/sim/lib/copilot/tools/client/init-tool-configs.ts +++ b/apps/sim/lib/copilot/tools/client/init-tool-configs.ts @@ -35,15 +35,14 @@ import './user/set-environment-variables' // Re-export UI config utilities for convenience export { + getSubagentLabels, getToolUIConfig, - isSubagentTool, - isSpecialTool, hasInterrupt, - getSubagentLabels, - type ToolUIConfig, - type SubagentConfig, type InterruptConfig, - type SecondaryActionConfig, + isSpecialTool, + isSubagentTool, type ParamsTableConfig, + type SecondaryActionConfig, + type SubagentConfig, + type ToolUIConfig, } from './ui-config' - diff --git a/apps/sim/lib/copilot/tools/client/ui-config.ts b/apps/sim/lib/copilot/tools/client/ui-config.ts index a49e39ebde..6fac1645c7 100644 --- a/apps/sim/lib/copilot/tools/client/ui-config.ts +++ b/apps/sim/lib/copilot/tools/client/ui-config.ts @@ -236,4 +236,3 @@ export function getSubagentLabels( export function getAllToolUIConfigs(): Record { return { ...toolUIConfigs } } - diff --git a/apps/sim/lib/copilot/tools/client/workflow/create-workspace-mcp-server.ts b/apps/sim/lib/copilot/tools/client/workflow/create-workspace-mcp-server.ts index 8a60094d5e..f50832184f 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/create-workspace-mcp-server.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/create-workspace-mcp-server.ts @@ -153,4 +153,3 @@ export class CreateWorkspaceMcpServerClientTool extends BaseClientTool { await this.handleAccept(args) } } - diff --git a/apps/sim/lib/copilot/tools/client/workflow/deploy-api.ts b/apps/sim/lib/copilot/tools/client/workflow/deploy-api.ts index 35c7c21ced..5b4d9c0b40 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/deploy-api.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/deploy-api.ts @@ -288,4 +288,3 @@ export class DeployApiClientTool extends BaseClientTool { // Register UI config at module load registerToolUIConfig(DeployApiClientTool.id, DeployApiClientTool.metadata.uiConfig!) - diff --git a/apps/sim/lib/copilot/tools/client/workflow/deploy-chat.ts b/apps/sim/lib/copilot/tools/client/workflow/deploy-chat.ts index f7e63c29a0..08fdfe3148 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/deploy-chat.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/deploy-chat.ts @@ -363,4 +363,3 @@ export class DeployChatClientTool extends BaseClientTool { // Register UI config at module load registerToolUIConfig(DeployChatClientTool.id, DeployChatClientTool.metadata.uiConfig!) - diff --git a/apps/sim/lib/copilot/tools/client/workflow/deploy-mcp.ts b/apps/sim/lib/copilot/tools/client/workflow/deploy-mcp.ts index 87ebd233d7..080498473c 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/deploy-mcp.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/deploy-mcp.ts @@ -6,7 +6,6 @@ import { ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config' -import { useCopilotStore } from '@/stores/panel/copilot/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' export interface ParameterDescription { @@ -118,7 +117,9 @@ export class DeployMcpClientTool extends BaseClientTool { } // Check if workflow is deployed - const deploymentStatus = useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId) + const deploymentStatus = useWorkflowRegistry + .getState() + .getWorkflowDeploymentStatus(workflowId) if (!deploymentStatus?.isDeployed) { throw new Error( 'Workflow must be deployed before adding as an MCP tool. Use deploy_api first.' @@ -208,4 +209,3 @@ export class DeployMcpClientTool extends BaseClientTool { // Register UI config at module load registerToolUIConfig(DeployMcpClientTool.id, DeployMcpClientTool.metadata.uiConfig!) - diff --git a/apps/sim/lib/copilot/tools/client/workflow/list-workspace-mcp-servers.ts b/apps/sim/lib/copilot/tools/client/workflow/list-workspace-mcp-servers.ts index 763b5bea48..a1aff45288 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/list-workspace-mcp-servers.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/list-workspace-mcp-servers.ts @@ -92,10 +92,14 @@ export class ListWorkspaceMcpServersClientTool extends BaseClientTool { { servers: [], count: 0 } ) } else { - await this.markToolComplete(200, `Found ${servers.length} MCP server(s) in the workspace.`, { - servers, - count: servers.length, - }) + await this.markToolComplete( + 200, + `Found ${servers.length} MCP server(s) in the workspace.`, + { + servers, + count: servers.length, + } + ) } logger.info(`Listed ${servers.length} MCP servers`) @@ -106,4 +110,3 @@ export class ListWorkspaceMcpServersClientTool extends BaseClientTool { } } } - diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index 438380f6ad..9c49d38041 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -57,7 +57,6 @@ import { DeployApiClientTool } from '@/lib/copilot/tools/client/workflow/deploy- import { DeployChatClientTool } from '@/lib/copilot/tools/client/workflow/deploy-chat' import { DeployMcpClientTool } from '@/lib/copilot/tools/client/workflow/deploy-mcp' import { EditWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/edit-workflow' -import { ListWorkspaceMcpServersClientTool } from '@/lib/copilot/tools/client/workflow/list-workspace-mcp-servers' import { GetBlockOutputsClientTool } from '@/lib/copilot/tools/client/workflow/get-block-outputs' import { GetBlockUpstreamReferencesClientTool } from '@/lib/copilot/tools/client/workflow/get-block-upstream-references' import { GetUserWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/get-user-workflow' @@ -65,6 +64,7 @@ import { GetWorkflowConsoleClientTool } from '@/lib/copilot/tools/client/workflo import { GetWorkflowDataClientTool } from '@/lib/copilot/tools/client/workflow/get-workflow-data' import { GetWorkflowFromNameClientTool } from '@/lib/copilot/tools/client/workflow/get-workflow-from-name' import { ListUserWorkflowsClientTool } from '@/lib/copilot/tools/client/workflow/list-user-workflows' +import { ListWorkspaceMcpServersClientTool } from '@/lib/copilot/tools/client/workflow/list-workspace-mcp-servers' import { ManageCustomToolClientTool } from '@/lib/copilot/tools/client/workflow/manage-custom-tool' import { ManageMcpToolClientTool } from '@/lib/copilot/tools/client/workflow/manage-mcp-tool' import { RunWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/run-workflow' @@ -399,9 +399,9 @@ function normalizeMessagesForUI(messages: CopilotMessage[]): CopilotMessage[] { if (message.role === 'assistant') { logger.info('[normalizeMessagesForUI] Loading assistant message', { id: message.id, - hasContent: !!(message.content && message.content.trim()), + hasContent: !!message.content?.trim(), contentBlockCount: message.contentBlocks?.length || 0, - contentBlockTypes: (message.contentBlocks as any[])?.map(b => b?.type) || [], + contentBlockTypes: (message.contentBlocks as any[])?.map((b) => b?.type) || [], }) } } @@ -642,9 +642,9 @@ function serializeMessagesForDB(messages: CopilotMessage[]): any[] { if (msg.role === 'assistant') { logger.info('[serializeMessagesForDB] Input assistant message', { id: msg.id, - hasContent: !!(msg.content && msg.content.trim()), + hasContent: !!msg.content?.trim(), contentBlockCount: msg.contentBlocks?.length || 0, - contentBlockTypes: (msg.contentBlocks as any[])?.map(b => b?.type) || [], + contentBlockTypes: (msg.contentBlocks as any[])?.map((b) => b?.type) || [], }) } } @@ -652,12 +652,15 @@ function serializeMessagesForDB(messages: CopilotMessage[]): any[] { logger.info('[serializeMessagesForDB] Serialized messages', { inputCount: messages.length, outputCount: result.length, - sample: result.length > 0 ? { - role: result[result.length - 1].role, - hasContent: !!result[result.length - 1].content, - contentBlockCount: result[result.length - 1].contentBlocks?.length || 0, - toolCallCount: result[result.length - 1].toolCalls?.length || 0, - } : null + sample: + result.length > 0 + ? { + role: result[result.length - 1].role, + hasContent: !!result[result.length - 1].content, + contentBlockCount: result[result.length - 1].contentBlocks?.length || 0, + toolCallCount: result[result.length - 1].toolCalls?.length || 0, + } + : null, }) return result @@ -3139,7 +3142,7 @@ export const useCopilotStore = create()( contentLength: lastMsg.content?.length || 0, hasContentBlocks: !!lastMsg.contentBlocks, contentBlockCount: lastMsg.contentBlocks?.length || 0, - contentBlockTypes: (lastMsg.contentBlocks as any[])?.map(b => b?.type) || [], + contentBlockTypes: (lastMsg.contentBlocks as any[])?.map((b) => b?.type) || [], }) } const dbMessages = validateMessagesForLLM(currentMessages) diff --git a/apps/sim/stores/workflow-diff/store.ts b/apps/sim/stores/workflow-diff/store.ts index 54ee8b69d9..dcee3be910 100644 --- a/apps/sim/stores/workflow-diff/store.ts +++ b/apps/sim/stores/workflow-diff/store.ts @@ -448,9 +448,11 @@ export const useWorkflowDiffStore = create { if (toolCallId) { - getClientTool(toolCallId)?.handleAccept?.()?.catch?.((error: Error) => { - logger.warn('Failed to notify tool accept state', { error }) - }) + getClientTool(toolCallId) + ?.handleAccept?.() + ?.catch?.((error: Error) => { + logger.warn('Failed to notify tool accept state', { error }) + }) } }) }, @@ -554,9 +556,11 @@ export const useWorkflowDiffStore = create { if (toolCallId) { - getClientTool(toolCallId)?.handleReject?.()?.catch?.((error: Error) => { - logger.warn('Failed to notify tool reject state', { error }) - }) + getClientTool(toolCallId) + ?.handleReject?.() + ?.catch?.((error: Error) => { + logger.warn('Failed to notify tool reject state', { error }) + }) } }) }, From ae6e29512a42806d781d33c1640f6ad68d40f6b6 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 9 Jan 2026 18:28:05 -0800 Subject: [PATCH 32/39] Previous options should not be selectable --- .../copilot/components/copilot-message/copilot-message.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx index c51727d752..5ec4b606ae 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx @@ -509,11 +509,12 @@ const CopilotMessage: FC = memo( )} {/* Options selector when agent presents choices - streams in but disabled until complete */} + {/* Disabled for previous messages (not isLastMessage) so only the latest options are interactive */} {parsedTags?.options && Object.keys(parsedTags.options).length > 0 && ( Date: Fri, 9 Jan 2026 18:32:26 -0800 Subject: [PATCH 33/39] Enable images --- .../components/user-input/hooks/use-file-attachments.ts | 5 +++-- .../components/copilot/components/user-input/user-input.tsx | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts index dfed765148..7587f69a9c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts @@ -178,11 +178,12 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { /** * Opens file picker dialog + * Note: We allow file selection even when isLoading (streaming) so users can prepare images for the next message */ const handleFileSelect = useCallback(() => { - if (disabled || isLoading) return + if (disabled) return fileInputRef.current?.click() - }, [disabled, isLoading]) + }, [disabled]) /** * Handles file input change event 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 2b705b83ed..a9c293222e 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 @@ -746,7 +746,7 @@ const UserInput = forwardRef( title='Attach file' className={cn( 'cursor-pointer rounded-[6px] border-0 bg-transparent p-[0px] dark:bg-transparent', - (disabled || isLoading) && 'cursor-not-allowed opacity-50' + disabled && 'cursor-not-allowed opacity-50' )} > @@ -802,7 +802,7 @@ const UserInput = forwardRef( - {/* Hidden File Input */} + {/* Hidden File Input - enabled during streaming so users can prepare images for the next message */} ( className='hidden' accept='image/*' multiple - disabled={disabled || isLoading} + disabled={disabled} /> From 41d767e17021c7572f19e64c234854135ef209a8 Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Fri, 9 Jan 2026 17:09:49 -0800 Subject: [PATCH 34/39] improvement(copilot): ui/ux --- .../components/command-list/command-list.tsx | 6 +- .../components/thinking-block.tsx | 12 +- .../copilot-message/copilot-message.tsx | 2 +- .../components/tool-call/tool-call.tsx | 167 ++++++++++-------- .../panel/components/copilot/copilot.tsx | 9 +- .../components/terminal/terminal.tsx | 33 ++++ .../tools/client/blocks/get-block-config.ts | 12 +- .../tools/client/blocks/get-block-options.ts | 12 +- .../lib/copilot/tools/client/other/auth.ts | 4 +- .../tools/client/other/checkoff-todo.ts | 2 +- .../copilot/tools/client/other/custom-tool.ts | 8 +- .../lib/copilot/tools/client/other/debug.ts | 4 +- .../lib/copilot/tools/client/other/deploy.ts | 4 +- .../lib/copilot/tools/client/other/edit.ts | 4 +- .../copilot/tools/client/other/evaluate.ts | 4 +- .../lib/copilot/tools/client/other/info.ts | 6 +- .../copilot/tools/client/other/knowledge.ts | 6 +- .../tools/client/other/make-api-request.ts | 2 +- .../client/other/mark-todo-in-progress.ts | 2 +- .../lib/copilot/tools/client/other/plan.ts | 4 +- .../copilot/tools/client/other/research.ts | 4 +- .../client/other/search-documentation.ts | 2 +- .../tools/client/other/search-online.ts | 2 +- .../lib/copilot/tools/client/other/sleep.ts | 6 +- .../lib/copilot/tools/client/other/test.ts | 4 +- .../lib/copilot/tools/client/other/tour.ts | 8 +- .../copilot/tools/client/other/workflow.ts | 6 +- .../tools/client/workflow/deploy-api.ts | 2 +- .../tools/client/workflow/deploy-chat.ts | 2 +- .../client/workflow/get-workflow-data.ts | 2 +- .../workflow/list-workspace-mcp-servers.ts | 2 +- .../tools/client/workflow/run-workflow.ts | 4 +- .../workflow/set-global-workflow-variables.ts | 2 +- 33 files changed, 209 insertions(+), 140 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/command-list/command-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/command-list/command-list.tsx index 4bf50085c5..405633109d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/command-list/command-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/command-list/command-list.tsx @@ -2,10 +2,10 @@ import { useCallback } from 'react' import { createLogger } from '@sim/logger' -import { Layout, LibraryBig, Search } from 'lucide-react' +import { Layout, Search } from 'lucide-react' import Image from 'next/image' import { useParams, useRouter } from 'next/navigation' -import { Button } from '@/components/emcn' +import { Button, Library } from '@/components/emcn' import { AgentIcon } from '@/components/icons' import { cn } from '@/lib/core/utils/cn' import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' @@ -41,7 +41,7 @@ const commands: CommandItem[] = [ }, { label: 'Logs', - icon: LibraryBig, + icon: Library, shortcut: 'L', }, { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx index c92e01ba26..e1bfda0baa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx @@ -184,7 +184,7 @@ export function ThinkingBlock({ // During streaming: show header with shimmer effect + expanded content if (!isThinkingDone) { return ( -
+
{/* Define shimmer keyframes */}