From ff632058ce8b1756488a64d9555da641250ad228 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 15 Jan 2026 10:55:40 -0800 Subject: [PATCH 01/13] Fix edit workflow returning bad state --- .../tools/client/workflow/edit-workflow.ts | 30 +++++----- apps/sim/stores/panel/copilot/store.ts | 55 ++++++++++++++----- 2 files changed, 59 insertions(+), 26 deletions(-) diff --git a/apps/sim/lib/copilot/tools/client/workflow/edit-workflow.ts b/apps/sim/lib/copilot/tools/client/workflow/edit-workflow.ts index e65e89244e..55ffdaa930 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/edit-workflow.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/edit-workflow.ts @@ -38,6 +38,18 @@ export class EditWorkflowClientTool extends BaseClientTool { super(toolCallId, EditWorkflowClientTool.id, EditWorkflowClientTool.metadata) } + async markToolComplete(status: number, message?: any, data?: any): Promise { + const logger = createLogger('EditWorkflowClientTool') + logger.info('markToolComplete payload', { + toolCallId: this.toolCallId, + toolName: this.name, + status, + message, + data, + }) + return super.markToolComplete(status, message, data) + } + /** * Get sanitized workflow JSON from a workflow state, merge subblocks, and sanitize for copilot * This matches what get_user_workflow returns @@ -173,21 +185,13 @@ export class EditWorkflowClientTool extends BaseClientTool { async execute(args?: EditWorkflowArgs): Promise { const logger = createLogger('EditWorkflowClientTool') + if (this.hasExecuted) { + logger.info('execute skipped (already executed)', { toolCallId: this.toolCallId }) + return + } + // Use timeout protection to ensure tool always completes await this.executeWithTimeout(async () => { - if (this.hasExecuted) { - logger.info('execute skipped (already executed)', { toolCallId: this.toolCallId }) - // Even if skipped, ensure we mark complete with current workflow state - if (!this.hasBeenMarkedComplete()) { - const currentWorkflowJson = this.getCurrentWorkflowJsonSafe(logger) - await this.markToolComplete( - 200, - 'Tool already executed', - currentWorkflowJson ? { userWorkflow: currentWorkflowJson } : undefined - ) - } - return - } this.hasExecuted = true logger.info('execute called', { toolCallId: this.toolCallId, argsProvided: !!args }) this.setState(ClientToolCallState.executing) diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index 2babf87c4b..e0605be108 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -1344,6 +1344,25 @@ const sseHandlers: Record = { context.contentBlocks.push(context.currentTextBlock) } + const splitTrailingPartialTag = ( + text: string, + tags: string[] + ): { text: string; remaining: string } => { + const partialIndex = text.lastIndexOf('<') + if (partialIndex < 0) { + return { text, remaining: '' } + } + const possibleTag = text.substring(partialIndex) + const matchesTagStart = tags.some((tag) => tag.startsWith(possibleTag)) + if (!matchesTagStart) { + return { text, remaining: '' } + } + return { + text: text.substring(0, partialIndex), + remaining: possibleTag, + } + } + while (contentToProcess.length > 0) { // Handle design_workflow tags (takes priority over other content processing) if (context.isInDesignWorkflowBlock) { @@ -1363,13 +1382,17 @@ const sseHandlers: Record = { hasProcessedContent = true } else { // Still in design_workflow block, accumulate content - context.designWorkflowContent += contentToProcess + const { text, remaining } = splitTrailingPartialTag(contentToProcess, ['']) + context.designWorkflowContent += text // Update store with partial content for streaming effect (available in all modes) set({ streamingPlanContent: context.designWorkflowContent }) - contentToProcess = '' + contentToProcess = remaining hasProcessedContent = true + if (remaining) { + break + } } continue } @@ -1491,18 +1514,24 @@ const sseHandlers: Record = { contentToProcess = contentToProcess.substring(endMatch.index + endMatch[0].length) hasProcessedContent = true } else { - if (context.currentThinkingBlock) { - context.currentThinkingBlock.content += contentToProcess - } else { - context.currentThinkingBlock = contentBlockPool.get() - context.currentThinkingBlock.type = THINKING_BLOCK_TYPE - context.currentThinkingBlock.content = contentToProcess - context.currentThinkingBlock.timestamp = Date.now() - context.currentThinkingBlock.startTime = Date.now() - context.contentBlocks.push(context.currentThinkingBlock) + const { text, remaining } = splitTrailingPartialTag(contentToProcess, ['']) + if (text) { + if (context.currentThinkingBlock) { + context.currentThinkingBlock.content += text + } else { + context.currentThinkingBlock = contentBlockPool.get() + context.currentThinkingBlock.type = THINKING_BLOCK_TYPE + context.currentThinkingBlock.content = text + context.currentThinkingBlock.timestamp = Date.now() + context.currentThinkingBlock.startTime = Date.now() + context.contentBlocks.push(context.currentThinkingBlock) + } + hasProcessedContent = true + } + contentToProcess = remaining + if (remaining) { + break } - contentToProcess = '' - hasProcessedContent = true } } else { const startMatch = thinkingStartRegex.exec(contentToProcess) From 0a9676117d5b232ec4616f5c708291f636681f7e Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 15 Jan 2026 11:48:29 -0800 Subject: [PATCH 02/13] Fix block id edit, slash commands at end, thinking tag resolution, add continue button --- apps/sim/app/api/copilot/chat/route.ts | 51 +-- .../api/copilot/chat/update-messages/route.ts | 3 +- apps/sim/app/api/copilot/user-models/route.ts | 9 +- .../components/tool-call/tool-call.tsx | 6 +- .../hooks/use-context-management.ts | 9 +- .../user-input/hooks/use-mention-tokens.ts | 9 + apps/sim/lib/copilot/api.ts | 35 +- apps/sim/lib/copilot/models.ts | 36 ++ apps/sim/stores/panel/copilot/store.ts | 392 ++++++++---------- apps/sim/stores/panel/copilot/types.ts | 28 +- 10 files changed, 268 insertions(+), 310 deletions(-) create mode 100644 apps/sim/lib/copilot/models.ts diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index c29d149e08..a4c845b461 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { getSession } from '@/lib/auth' import { generateChatTitle } from '@/lib/copilot/chat-title' import { getCopilotModel } from '@/lib/copilot/config' +import { COPILOT_MODEL_IDS, COPILOT_REQUEST_MODES } from '@/lib/copilot/models' import { SIM_AGENT_API_URL_DEFAULT, SIM_AGENT_VERSION } from '@/lib/copilot/constants' import { authenticateCopilotRequestSessionOnly, @@ -40,34 +41,8 @@ const ChatMessageSchema = z.object({ userMessageId: z.string().optional(), // ID from frontend for the user message chatId: z.string().optional(), workflowId: z.string().min(1, 'Workflow ID is required'), - model: z - .enum([ - 'gpt-5-fast', - 'gpt-5', - 'gpt-5-medium', - 'gpt-5-high', - 'gpt-5.1-fast', - 'gpt-5.1', - 'gpt-5.1-medium', - 'gpt-5.1-high', - 'gpt-5-codex', - 'gpt-5.1-codex', - 'gpt-5.2', - 'gpt-5.2-codex', - 'gpt-5.2-pro', - 'gpt-4o', - 'gpt-4.1', - 'o3', - 'claude-4-sonnet', - 'claude-4.5-haiku', - 'claude-4.5-sonnet', - 'claude-4.5-opus', - 'claude-4.1-opus', - 'gemini-3-pro', - ]) - .optional() - .default('claude-4.5-opus'), - mode: z.enum(['ask', 'agent', 'plan']).optional().default('agent'), + model: z.enum(COPILOT_MODEL_IDS).optional().default('claude-4.5-opus'), + mode: z.enum(COPILOT_REQUEST_MODES).optional().default('agent'), prefetch: z.boolean().optional(), createNewChat: z.boolean().optional().default(false), stream: z.boolean().optional().default(true), @@ -295,7 +270,8 @@ export async function POST(req: NextRequest) { } const defaults = getCopilotModel('chat') - const modelToUse = env.COPILOT_MODEL || defaults.model + const selectedModel = model || defaults.model + const envModel = env.COPILOT_MODEL || defaults.model let providerConfig: CopilotProviderConfig | undefined const providerEnv = env.COPILOT_PROVIDER as any @@ -304,7 +280,7 @@ export async function POST(req: NextRequest) { if (providerEnv === 'azure-openai') { providerConfig = { provider: 'azure-openai', - model: modelToUse, + model: envModel, apiKey: env.AZURE_OPENAI_API_KEY, apiVersion: 'preview', endpoint: env.AZURE_OPENAI_ENDPOINT, @@ -312,7 +288,7 @@ export async function POST(req: NextRequest) { } else if (providerEnv === 'vertex') { providerConfig = { provider: 'vertex', - model: modelToUse, + model: envModel, apiKey: env.COPILOT_API_KEY, vertexProject: env.VERTEX_PROJECT, vertexLocation: env.VERTEX_LOCATION, @@ -320,12 +296,15 @@ export async function POST(req: NextRequest) { } else { providerConfig = { provider: providerEnv, - model: modelToUse, + model: selectedModel, apiKey: env.COPILOT_API_KEY, } } } + const effectiveMode = mode === 'agent' ? 'build' : mode + const transportMode = effectiveMode === 'build' ? 'agent' : effectiveMode + // Determine conversationId to use for this request const effectiveConversationId = (currentChat?.conversationId as string | undefined) || conversationId @@ -345,7 +324,7 @@ export async function POST(req: NextRequest) { } } | null = null - if (mode === 'agent') { + if (effectiveMode === 'build') { // Build base tools (executed locally, not deferred) // Include function_execute for code execution capability baseTools = [ @@ -452,8 +431,8 @@ export async function POST(req: NextRequest) { userId: authenticatedUserId, stream: stream, streamToolCalls: true, - model: model, - mode: mode, + model: selectedModel, + mode: transportMode, messageId: userMessageIdToUse, version: SIM_AGENT_VERSION, ...(providerConfig ? { provider: providerConfig } : {}), @@ -477,7 +456,7 @@ export async function POST(req: NextRequest) { hasConversationId: !!effectiveConversationId, hasFileAttachments: processedFileContents.length > 0, messageLength: message.length, - mode, + mode: effectiveMode, hasTools: integrationTools.length > 0, toolCount: integrationTools.length, hasBaseTools: baseTools.length > 0, 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 217ba0b058..cc38bfbb63 100644 --- a/apps/sim/app/api/copilot/chat/update-messages/route.ts +++ b/apps/sim/app/api/copilot/chat/update-messages/route.ts @@ -11,6 +11,7 @@ import { createRequestTracker, createUnauthorizedResponse, } from '@/lib/copilot/request-helpers' +import { COPILOT_MODES } from '@/lib/copilot/models' const logger = createLogger('CopilotChatUpdateAPI') @@ -45,7 +46,7 @@ const UpdateMessagesSchema = z.object({ planArtifact: z.string().nullable().optional(), config: z .object({ - mode: z.enum(['ask', 'build', 'plan']).optional(), + mode: z.enum(COPILOT_MODES).optional(), model: z.string().optional(), }) .nullable() diff --git a/apps/sim/app/api/copilot/user-models/route.ts b/apps/sim/app/api/copilot/user-models/route.ts index 5e2f22f13d..ead14a5e9d 100644 --- a/apps/sim/app/api/copilot/user-models/route.ts +++ b/apps/sim/app/api/copilot/user-models/route.ts @@ -2,12 +2,13 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import type { CopilotModelId } from '@/lib/copilot/models' import { db } from '@/../../packages/db' import { settings } from '@/../../packages/db/schema' const logger = createLogger('CopilotUserModelsAPI') -const DEFAULT_ENABLED_MODELS: Record = { +const DEFAULT_ENABLED_MODELS: Record = { 'gpt-4o': false, 'gpt-4.1': false, 'gpt-5-fast': false, @@ -28,7 +29,7 @@ const DEFAULT_ENABLED_MODELS: Record = { 'claude-4.5-haiku': true, 'claude-4.5-sonnet': true, 'claude-4.5-opus': true, - // 'claude-4.1-opus': true, + 'claude-4.1-opus': false, 'gemini-3-pro': true, } @@ -54,7 +55,9 @@ export async function GET(request: NextRequest) { const mergedModels = { ...DEFAULT_ENABLED_MODELS } for (const [modelId, enabled] of Object.entries(userModelsMap)) { - mergedModels[modelId] = enabled + if (modelId in mergedModels) { + mergedModels[modelId as CopilotModelId] = enabled + } } const hasNewModels = Object.keys(DEFAULT_ENABLED_MODELS).some( 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 65a09e6042..7987d20435 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 @@ -1446,8 +1446,10 @@ function WorkflowEditSummary({ toolCall }: { toolCall: CopilotToolCall }) { blockType = blockType || op.block_type || '' } - // Fallback name to type or ID - if (!blockName) blockName = blockType || blockId + if (!blockName) blockName = blockType || '' + if (!blockName && !blockType) { + continue + } const change: BlockChange = { blockId, blockName, blockType } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts index 6b062e13f0..0dd4f3f16b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts @@ -22,6 +22,9 @@ interface UseContextManagementProps { export function useContextManagement({ message, initialContexts }: UseContextManagementProps) { const [selectedContexts, setSelectedContexts] = useState(initialContexts ?? []) const initializedRef = useRef(false) + const escapeRegex = useCallback((value: string) => { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + }, []) // Initialize with initial contexts when they're first provided (for edit mode) useEffect(() => { @@ -78,10 +81,8 @@ export function useContextManagement({ message, initialContexts }: UseContextMan // Check for slash command tokens or mention tokens based on kind const isSlashCommand = c.kind === 'slash_command' const prefix = isSlashCommand ? '/' : '@' - const tokenWithSpaces = ` ${prefix}${c.label} ` - const tokenAtStart = `${prefix}${c.label} ` - // Token can appear with leading space OR at the start of the message - return message.includes(tokenWithSpaces) || message.startsWith(tokenAtStart) + const tokenPattern = new RegExp(`(^|\\s)${escapeRegex(prefix)}${escapeRegex(c.label)}(\\s|$)`) + return tokenPattern.test(message) }) return filtered.length === prev.length ? prev : filtered }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts index 8d21fe83d0..cfc448d1aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts @@ -76,6 +76,15 @@ export function useMentionTokens({ ranges.push({ start: idx, end: idx + token.length, label }) fromIndex = idx + token.length } + + // Token at end of message without trailing space: "@label" or " /label" + const tokenAtEnd = `${prefix}${label}` + if (message.endsWith(tokenAtEnd)) { + const idx = message.lastIndexOf(tokenAtEnd) + const hasLeadingSpace = idx > 0 && message[idx - 1] === ' ' + const start = hasLeadingSpace ? idx - 1 : idx + ranges.push({ start, end: message.length, label }) + } } ranges.sort((a, b) => a.start - b.start) diff --git a/apps/sim/lib/copilot/api.ts b/apps/sim/lib/copilot/api.ts index 2eb2cbb30e..eb5e3e95ae 100644 --- a/apps/sim/lib/copilot/api.ts +++ b/apps/sim/lib/copilot/api.ts @@ -1,4 +1,9 @@ import { createLogger } from '@sim/logger' +import type { + CopilotMode, + CopilotModelId, + CopilotTransportMode, +} from '@/lib/copilot/models' const logger = createLogger('CopilotAPI') @@ -27,8 +32,8 @@ export interface CopilotMessage { * Chat config stored in database */ export interface CopilotChatConfig { - mode?: 'ask' | 'build' | 'plan' - model?: string + mode?: CopilotMode + model?: CopilotModelId } /** @@ -65,30 +70,8 @@ export interface SendMessageRequest { userMessageId?: string // ID from frontend for the user message chatId?: string workflowId?: string - mode?: 'ask' | 'agent' | 'plan' - model?: - | 'gpt-5-fast' - | 'gpt-5' - | 'gpt-5-medium' - | 'gpt-5-high' - | 'gpt-5.1-fast' - | 'gpt-5.1' - | 'gpt-5.1-medium' - | 'gpt-5.1-high' - | 'gpt-5-codex' - | 'gpt-5.1-codex' - | 'gpt-5.2' - | 'gpt-5.2-codex' - | 'gpt-5.2-pro' - | 'gpt-4o' - | 'gpt-4.1' - | 'o3' - | 'claude-4-sonnet' - | 'claude-4.5-haiku' - | 'claude-4.5-sonnet' - | 'claude-4.5-opus' - | 'claude-4.1-opus' - | 'gemini-3-pro' + mode?: CopilotMode | CopilotTransportMode + model?: CopilotModelId prefetch?: boolean createNewChat?: boolean stream?: boolean diff --git a/apps/sim/lib/copilot/models.ts b/apps/sim/lib/copilot/models.ts new file mode 100644 index 0000000000..20b30a68b7 --- /dev/null +++ b/apps/sim/lib/copilot/models.ts @@ -0,0 +1,36 @@ +export const COPILOT_MODEL_IDS = [ + 'gpt-5-fast', + 'gpt-5', + 'gpt-5-medium', + 'gpt-5-high', + 'gpt-5.1-fast', + 'gpt-5.1', + 'gpt-5.1-medium', + 'gpt-5.1-high', + 'gpt-5-codex', + 'gpt-5.1-codex', + 'gpt-5.2', + 'gpt-5.2-codex', + 'gpt-5.2-pro', + 'gpt-4o', + 'gpt-4.1', + 'o3', + 'claude-4-sonnet', + 'claude-4.5-haiku', + 'claude-4.5-sonnet', + 'claude-4.5-opus', + 'claude-4.1-opus', + 'gemini-3-pro', +] as const + +export type CopilotModelId = (typeof COPILOT_MODEL_IDS)[number] + +export const COPILOT_MODES = ['ask', 'build', 'plan'] as const +export type CopilotMode = (typeof COPILOT_MODES)[number] + +export const COPILOT_TRANSPORT_MODES = ['ask', 'agent', 'plan'] as const +export type CopilotTransportMode = (typeof COPILOT_TRANSPORT_MODES)[number] + +export const COPILOT_REQUEST_MODES = ['ask', 'build', 'plan', 'agent'] as const +export type CopilotRequestMode = (typeof COPILOT_REQUEST_MODES)[number] + diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index e0605be108..663d49bffa 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { create } from 'zustand' import { devtools } from 'zustand/middleware' import { type CopilotChat, sendStreamingMessage } from '@/lib/copilot/api' +import type { CopilotTransportMode } from '@/lib/copilot/models' import type { BaseClientToolMetadata, ClientToolDisplay, @@ -237,6 +238,7 @@ const TEXT_BLOCK_TYPE = 'text' const THINKING_BLOCK_TYPE = 'thinking' const DATA_PREFIX = 'data: ' const DATA_PREFIX_LENGTH = 6 +const CONTINUE_OPTIONS_TAG = '{"1":"Continue"}' // Resolve display text/icon for a tool based on its state function resolveToolDisplay( @@ -360,6 +362,7 @@ function abortAllInProgressTools(set: any, get: () => CopilotStore) { const { toolCallsById, messages } = get() const updatedMap = { ...toolCallsById } const abortedIds = new Set() + let hasUpdates = false for (const [id, tc] of Object.entries(toolCallsById)) { const st = tc.state as any // Abort anything not already terminal success/error/rejected/aborted @@ -373,11 +376,19 @@ function abortAllInProgressTools(set: any, get: () => CopilotStore) { updatedMap[id] = { ...tc, state: ClientToolCallState.aborted, + subAgentStreaming: false, display: resolveToolDisplay(tc.name, ClientToolCallState.aborted, id, (tc as any).params), } + hasUpdates = true + } else if (tc.subAgentStreaming) { + updatedMap[id] = { + ...tc, + subAgentStreaming: false, + } + hasUpdates = true } } - if (abortedIds.size > 0) { + if (abortedIds.size > 0 || hasUpdates) { set({ toolCallsById: updatedMap }) // Update inline blocks in-place for the latest assistant message only (most relevant) set((s: CopilotStore) => { @@ -826,6 +837,7 @@ interface StreamingContext { newChatId?: string doneEventCount: number streamComplete?: boolean + wasAborted?: boolean /** Track active subagent sessions by parent tool call ID */ subAgentParentToolCallId?: string /** Track subagent content per parent tool call */ @@ -843,6 +855,120 @@ type SSEHandler = ( set: any ) => Promise | void +function appendTextBlock(context: StreamingContext, text: string) { + if (!text) return + context.accumulatedContent.append(text) + if (context.currentTextBlock && context.contentBlocks.length > 0) { + const lastBlock = context.contentBlocks[context.contentBlocks.length - 1] + if (lastBlock.type === TEXT_BLOCK_TYPE && lastBlock === context.currentTextBlock) { + lastBlock.content += text + return + } + } + context.currentTextBlock = contentBlockPool.get() + context.currentTextBlock.type = TEXT_BLOCK_TYPE + context.currentTextBlock.content = text + context.currentTextBlock.timestamp = Date.now() + context.contentBlocks.push(context.currentTextBlock) +} + +function appendContinueOption(content: string): string { + if (//i.test(content)) return content + const suffix = content.trim().length > 0 ? '\n\n' : '' + return `${content}${suffix}${CONTINUE_OPTIONS_TAG}` +} + +function appendContinueOptionBlock(blocks: any[]): any[] { + if (!Array.isArray(blocks)) return blocks + const hasOptions = blocks.some( + (block) => block?.type === TEXT_BLOCK_TYPE && typeof block.content === 'string' && //i.test(block.content) + ) + if (hasOptions) return blocks + return [ + ...blocks, + { + type: TEXT_BLOCK_TYPE, + content: CONTINUE_OPTIONS_TAG, + timestamp: Date.now(), + }, + ] +} + +function beginThinkingBlock(context: StreamingContext) { + if (!context.currentThinkingBlock) { + context.currentThinkingBlock = contentBlockPool.get() + context.currentThinkingBlock.type = THINKING_BLOCK_TYPE + context.currentThinkingBlock.content = '' + context.currentThinkingBlock.timestamp = Date.now() + ;(context.currentThinkingBlock as any).startTime = Date.now() + context.contentBlocks.push(context.currentThinkingBlock) + } + context.isInThinkingBlock = true + context.currentTextBlock = null +} + +function appendThinkingContent(context: StreamingContext, text: string) { + if (!text) return + if (context.currentThinkingBlock) { + context.currentThinkingBlock.content += text + } else { + context.currentThinkingBlock = contentBlockPool.get() + context.currentThinkingBlock.type = THINKING_BLOCK_TYPE + context.currentThinkingBlock.content = text + context.currentThinkingBlock.timestamp = Date.now() + context.currentThinkingBlock.startTime = Date.now() + context.contentBlocks.push(context.currentThinkingBlock) + } + context.isInThinkingBlock = true + context.currentTextBlock = null +} + +function finalizeThinkingBlock(context: StreamingContext) { + if (context.currentThinkingBlock) { + context.currentThinkingBlock.duration = + Date.now() - (context.currentThinkingBlock.startTime || Date.now()) + } + context.isInThinkingBlock = false + context.currentThinkingBlock = null + context.currentTextBlock = null +} + +function upsertToolCallBlock(context: StreamingContext, toolCall: CopilotToolCall) { + let found = 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 === toolCall.id) { + context.contentBlocks[i] = { ...b, toolCall } + found = true + break + } + } + if (!found) { + context.contentBlocks.push({ type: 'tool_call', toolCall, timestamp: Date.now() }) + } +} + +function appendSubAgentText(context: StreamingContext, parentToolCallId: string, text: string) { + if (!context.subAgentContent[parentToolCallId]) { + context.subAgentContent[parentToolCallId] = '' + } + if (!context.subAgentBlocks[parentToolCallId]) { + context.subAgentBlocks[parentToolCallId] = [] + } + context.subAgentContent[parentToolCallId] += text + const blocks = context.subAgentBlocks[parentToolCallId] + const lastBlock = blocks[blocks.length - 1] + if (lastBlock && lastBlock.type === 'subagent_text') { + lastBlock.content = (lastBlock.content || '') + text + } else { + blocks.push({ + type: 'subagent_text', + content: text, + timestamp: Date.now(), + }) + } +} + const sseHandlers: Record = { chat_id: async (data, context, get) => { context.newChatId = data.chatId @@ -1033,17 +1159,7 @@ const sseHandlers: Record = { logger.info('[toolCallsById] map updated', updated) // Add/refresh inline content block - let found = 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 === toolCallId) { - context.contentBlocks[i] = { ...b, toolCall: tc } - found = true - break - } - } - if (!found) - context.contentBlocks.push({ type: 'tool_call', toolCall: tc, timestamp: Date.now() }) + upsertToolCallBlock(context, tc) updateStreamingMessage(set, context) } }, @@ -1079,18 +1195,7 @@ const sseHandlers: Record = { logger.info('[toolCallsById] → pending', { id, name, params: args }) // Ensure an inline content block exists/updated for this tool call - let found = 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 === id) { - context.contentBlocks[i] = { ...b, toolCall: next } - found = true - break - } - } - if (!found) { - context.contentBlocks.push({ type: 'tool_call', toolCall: next, timestamp: Date.now() }) - } + upsertToolCallBlock(context, next) updateStreamingMessage(set, context) // Prefer interface-based registry to determine interrupt and execute @@ -1275,44 +1380,18 @@ const sseHandlers: Record = { reasoning: (data, context, _get, set) => { const phase = (data && (data.phase || data?.data?.phase)) as string | undefined if (phase === 'start') { - if (!context.currentThinkingBlock) { - context.currentThinkingBlock = contentBlockPool.get() - context.currentThinkingBlock.type = THINKING_BLOCK_TYPE - context.currentThinkingBlock.content = '' - context.currentThinkingBlock.timestamp = Date.now() - ;(context.currentThinkingBlock as any).startTime = Date.now() - context.contentBlocks.push(context.currentThinkingBlock) - } - context.isInThinkingBlock = true - context.currentTextBlock = null + beginThinkingBlock(context) updateStreamingMessage(set, context) return } if (phase === 'end') { - if (context.currentThinkingBlock) { - ;(context.currentThinkingBlock as any).duration = - Date.now() - ((context.currentThinkingBlock as any).startTime || Date.now()) - } - context.isInThinkingBlock = false - context.currentThinkingBlock = null - context.currentTextBlock = null + finalizeThinkingBlock(context) updateStreamingMessage(set, context) return } const chunk: string = typeof data?.data === 'string' ? data.data : data?.content || '' if (!chunk) return - if (context.currentThinkingBlock) { - context.currentThinkingBlock.content += chunk - } else { - context.currentThinkingBlock = contentBlockPool.get() - context.currentThinkingBlock.type = THINKING_BLOCK_TYPE - context.currentThinkingBlock.content = chunk - context.currentThinkingBlock.timestamp = Date.now() - ;(context.currentThinkingBlock as any).startTime = Date.now() - context.contentBlocks.push(context.currentThinkingBlock) - } - context.isInThinkingBlock = true - context.currentTextBlock = null + appendThinkingContent(context, chunk) updateStreamingMessage(set, context) }, content: (data, context, get, set) => { @@ -1327,23 +1406,6 @@ const sseHandlers: Record = { const designWorkflowStartRegex = // const designWorkflowEndRegex = /<\/design_workflow>/ - const appendTextToContent = (text: string) => { - if (!text) return - context.accumulatedContent.append(text) - if (context.currentTextBlock && context.contentBlocks.length > 0) { - const lastBlock = context.contentBlocks[context.contentBlocks.length - 1] - if (lastBlock.type === TEXT_BLOCK_TYPE && lastBlock === context.currentTextBlock) { - lastBlock.content += text - return - } - } - context.currentTextBlock = contentBlockPool.get() - context.currentTextBlock.type = TEXT_BLOCK_TYPE - context.currentTextBlock.content = text - context.currentTextBlock.timestamp = Date.now() - context.contentBlocks.push(context.currentTextBlock) - } - const splitTrailingPartialTag = ( text: string, tags: string[] @@ -1403,7 +1465,7 @@ const sseHandlers: Record = { if (designStartMatch) { const textBeforeDesign = contentToProcess.substring(0, designStartMatch.index) if (textBeforeDesign) { - appendTextToContent(textBeforeDesign) + appendTextBlock(context, textBeforeDesign) hasProcessedContent = true } context.isInDesignWorkflowBlock = true @@ -1494,38 +1556,14 @@ const sseHandlers: Record = { const endMatch = thinkingEndRegex.exec(contentToProcess) if (endMatch) { const thinkingContent = contentToProcess.substring(0, endMatch.index) - if (context.currentThinkingBlock) { - context.currentThinkingBlock.content += thinkingContent - } else { - context.currentThinkingBlock = contentBlockPool.get() - context.currentThinkingBlock.type = THINKING_BLOCK_TYPE - context.currentThinkingBlock.content = thinkingContent - context.currentThinkingBlock.timestamp = Date.now() - context.currentThinkingBlock.startTime = Date.now() - context.contentBlocks.push(context.currentThinkingBlock) - } - context.isInThinkingBlock = false - if (context.currentThinkingBlock) { - context.currentThinkingBlock.duration = - Date.now() - (context.currentThinkingBlock.startTime || Date.now()) - } - context.currentThinkingBlock = null - context.currentTextBlock = null + appendThinkingContent(context, thinkingContent) + finalizeThinkingBlock(context) contentToProcess = contentToProcess.substring(endMatch.index + endMatch[0].length) hasProcessedContent = true } else { const { text, remaining } = splitTrailingPartialTag(contentToProcess, ['']) if (text) { - if (context.currentThinkingBlock) { - context.currentThinkingBlock.content += text - } else { - context.currentThinkingBlock = contentBlockPool.get() - context.currentThinkingBlock.type = THINKING_BLOCK_TYPE - context.currentThinkingBlock.content = text - context.currentThinkingBlock.timestamp = Date.now() - context.currentThinkingBlock.startTime = Date.now() - context.contentBlocks.push(context.currentThinkingBlock) - } + appendThinkingContent(context, text) hasProcessedContent = true } contentToProcess = remaining @@ -1538,25 +1576,7 @@ const sseHandlers: Record = { if (startMatch) { const textBeforeThinking = contentToProcess.substring(0, startMatch.index) if (textBeforeThinking) { - context.accumulatedContent.append(textBeforeThinking) - if (context.currentTextBlock && context.contentBlocks.length > 0) { - const lastBlock = context.contentBlocks[context.contentBlocks.length - 1] - if (lastBlock.type === TEXT_BLOCK_TYPE && lastBlock === context.currentTextBlock) { - lastBlock.content += textBeforeThinking - } else { - context.currentTextBlock = contentBlockPool.get() - context.currentTextBlock.type = TEXT_BLOCK_TYPE - context.currentTextBlock.content = textBeforeThinking - context.currentTextBlock.timestamp = Date.now() - context.contentBlocks.push(context.currentTextBlock) - } - } else { - context.currentTextBlock = contentBlockPool.get() - context.currentTextBlock.type = TEXT_BLOCK_TYPE - context.currentTextBlock.content = textBeforeThinking - context.currentTextBlock.timestamp = Date.now() - context.contentBlocks.push(context.currentTextBlock) - } + appendTextBlock(context, textBeforeThinking) hasProcessedContent = true } context.isInThinkingBlock = true @@ -1585,25 +1605,7 @@ const sseHandlers: Record = { remaining = contentToProcess.substring(partialTagIndex) } if (textToAdd) { - context.accumulatedContent.append(textToAdd) - if (context.currentTextBlock && context.contentBlocks.length > 0) { - const lastBlock = context.contentBlocks[context.contentBlocks.length - 1] - if (lastBlock.type === TEXT_BLOCK_TYPE && lastBlock === context.currentTextBlock) { - lastBlock.content += textToAdd - } else { - context.currentTextBlock = contentBlockPool.get() - context.currentTextBlock.type = TEXT_BLOCK_TYPE - context.currentTextBlock.content = textToAdd - context.currentTextBlock.timestamp = Date.now() - context.contentBlocks.push(context.currentTextBlock) - } - } else { - context.currentTextBlock = contentBlockPool.get() - context.currentTextBlock.type = TEXT_BLOCK_TYPE - context.currentTextBlock.content = textToAdd - context.currentTextBlock.timestamp = Date.now() - context.contentBlocks.push(context.currentTextBlock) - } + appendTextBlock(context, textToAdd) hasProcessedContent = true } contentToProcess = remaining @@ -1641,37 +1643,13 @@ const sseHandlers: Record = { stream_end: (_data, context, _get, set) => { if (context.pendingContent) { if (context.isInThinkingBlock && context.currentThinkingBlock) { - context.currentThinkingBlock.content += context.pendingContent + appendThinkingContent(context, context.pendingContent) } else if (context.pendingContent.trim()) { - context.accumulatedContent.append(context.pendingContent) - if (context.currentTextBlock && context.contentBlocks.length > 0) { - const lastBlock = context.contentBlocks[context.contentBlocks.length - 1] - if (lastBlock.type === TEXT_BLOCK_TYPE && lastBlock === context.currentTextBlock) { - lastBlock.content += context.pendingContent - } else { - context.currentTextBlock = contentBlockPool.get() - context.currentTextBlock.type = TEXT_BLOCK_TYPE - context.currentTextBlock.content = context.pendingContent - context.currentTextBlock.timestamp = Date.now() - context.contentBlocks.push(context.currentTextBlock) - } - } else { - context.currentTextBlock = contentBlockPool.get() - context.currentTextBlock.type = TEXT_BLOCK_TYPE - context.currentTextBlock.content = context.pendingContent - context.currentTextBlock.timestamp = Date.now() - context.contentBlocks.push(context.currentTextBlock) - } + appendTextBlock(context, context.pendingContent) } context.pendingContent = '' } - if (context.currentThinkingBlock) { - context.currentThinkingBlock.duration = - Date.now() - (context.currentThinkingBlock.startTime || Date.now()) - } - context.isInThinkingBlock = false - context.currentThinkingBlock = null - context.currentTextBlock = null + finalizeThinkingBlock(context) updateStreamingMessage(set, context) }, default: () => {}, @@ -1769,29 +1747,7 @@ const subAgentSSEHandlers: Record = { 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(), - }) - } + appendSubAgentText(context, parentToolCallId, data.data) updateToolCallWithSubAgentData(context, get, set, parentToolCallId) }, @@ -1802,34 +1758,13 @@ const subAgentSSEHandlers: Record = { 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(), - }) - } + appendSubAgentText(context, parentToolCallId, chunk) updateToolCallWithSubAgentData(context, get, set, parentToolCallId) }, @@ -2031,6 +1966,14 @@ const MIN_BATCH_INTERVAL = 16 const MAX_BATCH_INTERVAL = 50 const MAX_QUEUE_SIZE = 5 +function stopStreamingUpdates() { + if (streamingUpdateRAF !== null) { + cancelAnimationFrame(streamingUpdateRAF) + streamingUpdateRAF = null + } + streamingUpdateQueue.clear() +} + function createOptimizedContentBlocks(contentBlocks: any[]): any[] { const result: any[] = new Array(contentBlocks.length) for (let i = 0; i < contentBlocks.length; i++) { @@ -2577,7 +2520,7 @@ export const useCopilotStore = create()( } // Call copilot API - const apiMode: 'ask' | 'agent' | 'plan' = + const apiMode: CopilotTransportMode = mode === 'ask' ? 'ask' : mode === 'plan' ? 'plan' : 'agent' // Extract slash commands from contexts (lowercase) and filter them out from contexts @@ -2675,6 +2618,7 @@ export const useCopilotStore = create()( set({ isAborting: true }) try { abortController.abort() + stopStreamingUpdates() const lastMessage = messages[messages.length - 1] if (lastMessage && lastMessage.role === 'assistant') { const textContent = @@ -2682,10 +2626,17 @@ export const useCopilotStore = create()( ?.filter((b) => b.type === 'text') .map((b: any) => b.content) .join('') || '' + const nextContentBlocks = appendContinueOptionBlock( + lastMessage.contentBlocks ? [...lastMessage.contentBlocks] : [] + ) set((state) => ({ messages: state.messages.map((msg) => msg.id === lastMessage.id - ? { ...msg, content: textContent.trim() || 'Message was aborted' } + ? { + ...msg, + content: appendContinueOption(textContent.trim() || 'Message was aborted'), + contentBlocks: nextContentBlocks, + } : msg ), isSendingMessage: false, @@ -3089,7 +3040,14 @@ export const useCopilotStore = create()( try { for await (const data of parseSSEStream(reader, decoder)) { const { abortController } = get() - if (abortController?.signal.aborted) break + if (abortController?.signal.aborted) { + context.wasAborted = true + context.pendingContent = '' + finalizeThinkingBlock(context) + stopStreamingUpdates() + reader.cancel() + break + } // Log SSE events for debugging logger.info('[SSE] Received event', { @@ -3189,7 +3147,9 @@ export const useCopilotStore = create()( if (context.streamComplete) break } - if (sseHandlers.stream_end) sseHandlers.stream_end({}, context, get, set) + if (!context.wasAborted && sseHandlers.stream_end) { + sseHandlers.stream_end({}, context, get, set) + } if (streamingUpdateRAF !== null) { cancelAnimationFrame(streamingUpdateRAF) @@ -3206,6 +3166,9 @@ export const useCopilotStore = create()( : block ) } + if (context.wasAborted) { + sanitizedContentBlocks = appendContinueOptionBlock(sanitizedContentBlocks) + } if (context.contentBlocks) { context.contentBlocks.forEach((block) => { @@ -3216,12 +3179,15 @@ export const useCopilotStore = create()( } const finalContent = stripTodoTags(context.accumulatedContent.toString()) + const finalContentWithOptions = context.wasAborted + ? appendContinueOption(finalContent) + : finalContent set((state) => ({ messages: state.messages.map((msg) => msg.id === assistantMessageId ? { ...msg, - content: finalContent, + content: finalContentWithOptions, contentBlocks: sanitizedContentBlocks, } : msg diff --git a/apps/sim/stores/panel/copilot/types.ts b/apps/sim/stores/panel/copilot/types.ts index 996317348b..a6878bf69a 100644 --- a/apps/sim/stores/panel/copilot/types.ts +++ b/apps/sim/stores/panel/copilot/types.ts @@ -1,3 +1,5 @@ +import type { CopilotMode, CopilotModelId } from '@/lib/copilot/models' +export type { CopilotMode, CopilotModelId } from '@/lib/copilot/models' import type { ClientToolCallState, ClientToolDisplay } from '@/lib/copilot/tools/client/base-tool' export type ToolState = ClientToolCallState @@ -91,33 +93,9 @@ import type { CopilotChat as ApiCopilotChat } from '@/lib/copilot/api' export type CopilotChat = ApiCopilotChat -export type CopilotMode = 'ask' | 'build' | 'plan' - export interface CopilotState { mode: CopilotMode - selectedModel: - | 'gpt-5-fast' - | 'gpt-5' - | 'gpt-5-medium' - | 'gpt-5-high' - | 'gpt-5.1-fast' - | 'gpt-5.1' - | 'gpt-5.1-medium' - | 'gpt-5.1-high' - | 'gpt-5-codex' - | 'gpt-5.1-codex' - | 'gpt-5.2' - | 'gpt-5.2-codex' - | 'gpt-5.2-pro' - | 'gpt-4o' - | 'gpt-4.1' - | 'o3' - | 'claude-4-sonnet' - | 'claude-4.5-haiku' - | 'claude-4.5-sonnet' - | 'claude-4.5-opus' - | 'claude-4.1-opus' - | 'gemini-3-pro' + selectedModel: CopilotModelId agentPrefetch: boolean enabledModels: string[] | null // Null means not loaded yet, array of model IDs when loaded isCollapsed: boolean From 456d83381c0936b2ed99da14679e9561169d5211 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 15 Jan 2026 11:58:50 -0800 Subject: [PATCH 03/13] Clean up autosend and continue options and enable mention menu --- .../components/user-input/user-input.tsx | 14 +++------- apps/sim/stores/panel/copilot/store.ts | 28 +++++++++++++------ apps/sim/stores/panel/copilot/types.ts | 4 ++- 3 files changed, 26 insertions(+), 20 deletions(-) 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 a5e19fd130..50c0d45697 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 @@ -613,7 +613,7 @@ const UserInput = forwardRef( const insertTriggerAndOpenMenu = useCallback( (trigger: '@' | '/') => { - if (disabled || isLoading) return + if (disabled) return const textarea = mentionMenu.textareaRef.current if (!textarea) return @@ -642,7 +642,7 @@ const UserInput = forwardRef( } mentionMenu.setSubmenuActiveIndex(0) }, - [disabled, isLoading, mentionMenu, message, setMessage] + [disabled, mentionMenu, message, setMessage] ) const handleOpenMentionMenuWithAt = useCallback( @@ -735,10 +735,7 @@ const UserInput = forwardRef( variant='outline' onClick={handleOpenMentionMenuWithAt} title='Insert @' - className={cn( - 'cursor-pointer rounded-[6px] p-[4.5px]', - (disabled || isLoading) && 'cursor-not-allowed' - )} + className={cn('cursor-pointer rounded-[6px] p-[4.5px]', disabled && 'cursor-not-allowed')} > @@ -747,10 +744,7 @@ const UserInput = forwardRef( variant='outline' onClick={handleOpenSlashMenu} title='Insert /' - className={cn( - 'cursor-pointer rounded-[6px] p-[4.5px]', - (disabled || isLoading) && 'cursor-not-allowed' - )} + className={cn('cursor-pointer rounded-[6px] p-[4.5px]', disabled && 'cursor-not-allowed')} > / diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index 663d49bffa..089e79b41a 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -838,6 +838,7 @@ interface StreamingContext { doneEventCount: number streamComplete?: boolean wasAborted?: boolean + suppressContinueOption?: boolean /** Track active subagent sessions by parent tool call ID */ subAgentParentToolCallId?: string /** Track subagent content per parent tool call */ @@ -2104,6 +2105,7 @@ const initialState = { suppressAutoSelect: false, autoAllowedTools: [] as string[], messageQueue: [] as import('./types').QueuedMessage[], + suppressAbortContinueOption: false, } export const useCopilotStore = create()( @@ -2612,10 +2614,11 @@ export const useCopilotStore = create()( }, // Abort streaming - abortMessage: () => { + abortMessage: (options?: { suppressContinueOption?: boolean }) => { const { abortController, isSendingMessage, messages } = get() if (!isSendingMessage || !abortController) return - set({ isAborting: true }) + const suppressContinueOption = options?.suppressContinueOption === true + set({ isAborting: true, suppressAbortContinueOption: suppressContinueOption }) try { abortController.abort() stopStreamingUpdates() @@ -2626,15 +2629,17 @@ export const useCopilotStore = create()( ?.filter((b) => b.type === 'text') .map((b: any) => b.content) .join('') || '' - const nextContentBlocks = appendContinueOptionBlock( - lastMessage.contentBlocks ? [...lastMessage.contentBlocks] : [] - ) + const nextContentBlocks = suppressContinueOption + ? lastMessage.contentBlocks ?? [] + : appendContinueOptionBlock(lastMessage.contentBlocks ? [...lastMessage.contentBlocks] : []) set((state) => ({ messages: state.messages.map((msg) => msg.id === lastMessage.id ? { ...msg, - content: appendContinueOption(textContent.trim() || 'Message was aborted'), + content: suppressContinueOption + ? textContent.trim() || 'Message was aborted' + : appendContinueOption(textContent.trim() || 'Message was aborted'), contentBlocks: nextContentBlocks, } : msg @@ -3042,6 +3047,11 @@ export const useCopilotStore = create()( const { abortController } = get() if (abortController?.signal.aborted) { context.wasAborted = true + const { suppressAbortContinueOption } = get() + context.suppressContinueOption = suppressAbortContinueOption === true + if (suppressAbortContinueOption) { + set({ suppressAbortContinueOption: false }) + } context.pendingContent = '' finalizeThinkingBlock(context) stopStreamingUpdates() @@ -3166,7 +3176,7 @@ export const useCopilotStore = create()( : block ) } - if (context.wasAborted) { + if (context.wasAborted && !context.suppressContinueOption) { sanitizedContentBlocks = appendContinueOptionBlock(sanitizedContentBlocks) } @@ -3179,7 +3189,7 @@ export const useCopilotStore = create()( } const finalContent = stripTodoTags(context.accumulatedContent.toString()) - const finalContentWithOptions = context.wasAborted + const finalContentWithOptions = context.wasAborted && !context.suppressContinueOption ? appendContinueOption(finalContent) : finalContent set((state) => ({ @@ -3704,7 +3714,7 @@ export const useCopilotStore = create()( // If currently sending, abort and send this one const { isSendingMessage } = get() if (isSendingMessage) { - get().abortMessage() + get().abortMessage({ suppressContinueOption: true }) // Wait a tick for abort to complete await new Promise((resolve) => setTimeout(resolve, 50)) } diff --git a/apps/sim/stores/panel/copilot/types.ts b/apps/sim/stores/panel/copilot/types.ts index a6878bf69a..05a4a6c126 100644 --- a/apps/sim/stores/panel/copilot/types.ts +++ b/apps/sim/stores/panel/copilot/types.ts @@ -115,6 +115,8 @@ export interface CopilotState { isSaving: boolean isRevertingCheckpoint: boolean isAborting: boolean + /** Skip adding Continue option on abort for queued send-now */ + suppressAbortContinueOption?: boolean error: string | null saveError: string | null @@ -175,7 +177,7 @@ export interface CopilotActions { messageId?: string } ) => Promise - abortMessage: () => void + abortMessage: (options?: { suppressContinueOption?: boolean }) => void sendImplicitFeedback: ( implicitFeedback: string, toolCallState?: 'accepted' | 'rejected' | 'error' From c232fc5cea536780c114f5f39aae87f274a9cd29 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 15 Jan 2026 15:02:14 -0800 Subject: [PATCH 04/13] Cleanup --- .../diff-controls/diff-controls.tsx | 192 ++---------------- .../hooks/use-checkpoint-management.ts | 4 +- apps/sim/stores/panel/copilot/store.ts | 162 +++++++++++++-- apps/sim/stores/panel/copilot/types.ts | 3 + apps/sim/stores/workflow-diff/store.ts | 55 ++++- 5 files changed, 216 insertions(+), 200 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 f6acb2e217..f87ce0130c 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 @@ -8,9 +8,6 @@ import { useNotificationStore } from '@/stores/notifications' import { useCopilotStore, usePanelStore } from '@/stores/panel' import { useTerminalStore } from '@/stores/terminal' import { useWorkflowDiffStore } from '@/stores/workflow-diff' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { mergeSubblockState } from '@/stores/workflows/utils' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' const logger = createLogger('DiffControls') const NOTIFICATION_WIDTH = 240 @@ -19,188 +16,29 @@ const NOTIFICATION_GAP = 16 export const DiffControls = memo(function DiffControls() { const isTerminalResizing = useTerminalStore((state) => state.isResizing) const isPanelResizing = usePanelStore((state) => state.isResizing) - const { isDiffReady, hasActiveDiff, acceptChanges, rejectChanges, baselineWorkflow } = - useWorkflowDiffStore( - useCallback( - (state) => ({ - isDiffReady: state.isDiffReady, - hasActiveDiff: state.hasActiveDiff, - acceptChanges: state.acceptChanges, - rejectChanges: state.rejectChanges, - baselineWorkflow: state.baselineWorkflow, - }), - [] - ) + const { isDiffReady, hasActiveDiff, acceptChanges, rejectChanges } = useWorkflowDiffStore( + useCallback( + (state) => ({ + isDiffReady: state.isDiffReady, + hasActiveDiff: state.hasActiveDiff, + acceptChanges: state.acceptChanges, + rejectChanges: state.rejectChanges, + }), + [] ) + ) - const { updatePreviewToolCallState, currentChat, messages } = useCopilotStore( + const { updatePreviewToolCallState } = useCopilotStore( useCallback( (state) => ({ updatePreviewToolCallState: state.updatePreviewToolCallState, - currentChat: state.currentChat, - messages: state.messages, }), [] ) ) - const { activeWorkflowId } = useWorkflowRegistry( - useCallback((state) => ({ activeWorkflowId: state.activeWorkflowId }), []) - ) - const allNotifications = useNotificationStore((state) => state.notifications) - const hasVisibleNotifications = useMemo(() => { - if (!activeWorkflowId) return false - return allNotifications.some((n) => !n.workflowId || n.workflowId === activeWorkflowId) - }, [allNotifications, activeWorkflowId]) - - const createCheckpoint = useCallback(async () => { - if (!activeWorkflowId || !currentChat?.id) { - logger.warn('Cannot create checkpoint: missing workflowId or chatId', { - workflowId: activeWorkflowId, - chatId: currentChat?.id, - }) - return false - } - - try { - logger.info('Creating checkpoint before accepting changes') - - // Use the baseline workflow (state before diff) instead of current state - // This ensures reverting to the checkpoint restores the pre-diff state - const rawState = baselineWorkflow || useWorkflowStore.getState().getWorkflowState() - - // The baseline already has merged subblock values, but we'll merge again to be safe - // This ensures all user inputs and subblock data are captured - const blocksWithSubblockValues = mergeSubblockState(rawState.blocks, activeWorkflowId) - - // Filter and complete blocks to ensure all required fields are present - // This matches the validation logic from /api/workflows/[id]/state - const filteredBlocks = Object.entries(blocksWithSubblockValues).reduce( - (acc, [blockId, block]) => { - if (block.type && block.name) { - // Ensure all required fields are present - acc[blockId] = { - ...block, - id: block.id || blockId, // Ensure id field is set - enabled: block.enabled !== undefined ? block.enabled : true, - horizontalHandles: - block.horizontalHandles !== undefined ? block.horizontalHandles : true, - height: block.height !== undefined ? block.height : 90, - subBlocks: block.subBlocks || {}, - outputs: block.outputs || {}, - data: block.data || {}, - position: block.position || { x: 0, y: 0 }, // Ensure position exists - } - } - return acc - }, - {} as typeof rawState.blocks - ) - - // Clean the workflow state - only include valid fields, exclude null/undefined values - const workflowState = { - blocks: filteredBlocks, - edges: rawState.edges || [], - loops: rawState.loops || {}, - parallels: rawState.parallels || {}, - lastSaved: rawState.lastSaved || Date.now(), - deploymentStatuses: rawState.deploymentStatuses || {}, - } - - logger.info('Prepared complete workflow state for checkpoint', { - blocksCount: Object.keys(workflowState.blocks).length, - edgesCount: workflowState.edges.length, - loopsCount: Object.keys(workflowState.loops).length, - parallelsCount: Object.keys(workflowState.parallels).length, - hasRequiredFields: Object.values(workflowState.blocks).every( - (block) => block.id && block.type && block.name && block.position - ), - hasSubblockValues: Object.values(workflowState.blocks).some((block) => - Object.values(block.subBlocks || {}).some( - (subblock) => subblock.value !== null && subblock.value !== undefined - ) - ), - sampleBlock: Object.values(workflowState.blocks)[0], - }) - - // Find the most recent user message ID from the current chat - const userMessages = messages.filter((msg) => msg.role === 'user') - const lastUserMessage = userMessages[userMessages.length - 1] - const messageId = lastUserMessage?.id - - logger.info('Creating checkpoint with message association', { - totalMessages: messages.length, - userMessageCount: userMessages.length, - lastUserMessageId: messageId, - chatId: currentChat.id, - entireMessageArray: messages, - allMessageIds: messages.map((m) => ({ - id: m.id, - role: m.role, - content: m.content.substring(0, 50), - })), - selectedUserMessages: userMessages.map((m) => ({ - id: m.id, - content: m.content.substring(0, 100), - })), - allRawMessageIds: messages.map((m) => m.id), - userMessageIds: userMessages.map((m) => m.id), - checkpointData: { - workflowId: activeWorkflowId, - chatId: currentChat.id, - messageId: messageId, - messageFound: !!lastUserMessage, - }, - }) - - const response = await fetch('/api/copilot/checkpoints', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - workflowId: activeWorkflowId, - chatId: currentChat.id, - messageId, - workflowState: JSON.stringify(workflowState), - }), - }) - - if (!response.ok) { - throw new Error(`Failed to create checkpoint: ${response.statusText}`) - } - - const result = await response.json() - const newCheckpoint = result.checkpoint - - logger.info('Checkpoint created successfully', { - messageId, - chatId: currentChat.id, - checkpointId: newCheckpoint?.id, - }) - - // Update the copilot store immediately to show the checkpoint icon - if (newCheckpoint && messageId) { - const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState() - const existingCheckpoints = currentCheckpoints[messageId] || [] - - const updatedCheckpoints = { - ...currentCheckpoints, - [messageId]: [newCheckpoint, ...existingCheckpoints], - } - - useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints }) - logger.info('Updated copilot store with new checkpoint', { - messageId, - checkpointId: newCheckpoint.id, - }) - } - - return true - } catch (error) { - logger.error('Failed to create checkpoint:', error) - return false - } - }, [activeWorkflowId, currentChat, messages, baselineWorkflow]) + const hasVisibleNotifications = allNotifications.length > 0 const handleAccept = useCallback(() => { logger.info('Accepting proposed changes with backup protection') @@ -238,12 +76,8 @@ export const DiffControls = memo(function DiffControls() { }) // 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) - }) - logger.info('Accept triggered; UI will update optimistically') - }, [createCheckpoint, updatePreviewToolCallState, acceptChanges]) + }, [updatePreviewToolCallState, acceptChanges]) const handleReject = useCallback(() => { logger.info('Rejecting proposed changes (optimistic)') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks/use-checkpoint-management.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks/use-checkpoint-management.ts index 180ad39fe7..b7dfafe957 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks/use-checkpoint-management.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks/use-checkpoint-management.ts @@ -57,7 +57,7 @@ export function useCheckpointManagement( const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState() const updatedCheckpoints = { ...currentCheckpoints, - [message.id]: messageCheckpoints.slice(1), + [message.id]: [], } useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints }) @@ -140,7 +140,7 @@ export function useCheckpointManagement( const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState() const updatedCheckpoints = { ...currentCheckpoints, - [message.id]: messageCheckpoints.slice(1), + [message.id]: [], } useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints }) diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index 089e79b41a..b07bc0a5b9 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -85,7 +85,9 @@ import type { } from '@/stores/panel/copilot/types' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { mergeSubblockState } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('CopilotStore') @@ -631,6 +633,97 @@ function createErrorMessage( } } +/** + * Builds a workflow snapshot suitable for checkpoint persistence. + */ +function buildCheckpointWorkflowState(workflowId: string): WorkflowState | null { + const rawState = useWorkflowStore.getState().getWorkflowState() + if (!rawState) return null + + const blocksWithSubblockValues = mergeSubblockState(rawState.blocks, workflowId) + + const filteredBlocks = Object.entries(blocksWithSubblockValues).reduce( + (acc, [blockId, block]) => { + if (block?.type && block?.name) { + acc[blockId] = { + ...block, + id: block.id || blockId, + enabled: block.enabled !== undefined ? block.enabled : true, + horizontalHandles: block.horizontalHandles !== undefined ? block.horizontalHandles : true, + height: block.height !== undefined ? block.height : 90, + subBlocks: block.subBlocks || {}, + outputs: block.outputs || {}, + data: block.data || {}, + position: block.position || { x: 0, y: 0 }, + } + } + return acc + }, + {} as WorkflowState['blocks'] + ) + + return { + blocks: filteredBlocks, + edges: rawState.edges || [], + loops: rawState.loops || {}, + parallels: rawState.parallels || {}, + lastSaved: rawState.lastSaved || Date.now(), + deploymentStatuses: rawState.deploymentStatuses || {}, + } +} + +/** + * Persists a previously captured snapshot as a workflow checkpoint. + */ +async function saveMessageCheckpoint( + messageId: string, + get: () => CopilotStore, + set: (partial: Partial | ((state: CopilotStore) => Partial)) => void +): Promise { + const { workflowId, currentChat, messageSnapshots, messageCheckpoints } = get() + if (!workflowId || !currentChat?.id) return false + + const snapshot = messageSnapshots[messageId] + if (!snapshot) return false + + const nextSnapshots = { ...messageSnapshots } + delete nextSnapshots[messageId] + set({ messageSnapshots: nextSnapshots }) + + try { + const response = await fetch('/api/copilot/checkpoints', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + workflowId, + chatId: currentChat.id, + messageId, + workflowState: JSON.stringify(snapshot), + }), + }) + + if (!response.ok) { + throw new Error(`Failed to create checkpoint: ${response.statusText}`) + } + + const result = await response.json() + const newCheckpoint = result.checkpoint + if (newCheckpoint) { + const existingCheckpoints = messageCheckpoints[messageId] || [] + const updatedCheckpoints = { + ...messageCheckpoints, + [messageId]: [newCheckpoint, ...existingCheckpoints], + } + set({ messageCheckpoints: updatedCheckpoints }) + } + + return true + } catch (error) { + logger.error('Failed to create checkpoint from snapshot:', error) + return false + } +} + function stripTodoTags(text: string): string { if (!text) return text return text @@ -1199,6 +1292,7 @@ const sseHandlers: Record = { upsertToolCallBlock(context, next) updateStreamingMessage(set, context) + // Prefer interface-based registry to determine interrupt and execute try { const def = name ? getTool(name) : undefined @@ -2082,6 +2176,7 @@ const initialState = { messages: [] as CopilotMessage[], checkpoints: [] as any[], messageCheckpoints: {} as Record, + messageSnapshots: {} as Record, isLoading: false, isLoadingChats: false, isLoadingCheckpoints: false, @@ -2128,7 +2223,7 @@ export const useCopilotStore = create()( // Abort all in-progress tools and clear any diff preview abortAllInProgressTools(set, get) try { - useWorkflowDiffStore.getState().clearDiff() + useWorkflowDiffStore.getState().clearDiff({ restoreBaseline: false }) } catch {} set({ @@ -2162,7 +2257,7 @@ export const useCopilotStore = create()( // Abort in-progress tools and clear diff when changing chats abortAllInProgressTools(set, get) try { - useWorkflowDiffStore.getState().clearDiff() + useWorkflowDiffStore.getState().clearDiff({ restoreBaseline: false }) } catch {} // Restore plan content and config (mode/model) from selected chat @@ -2255,7 +2350,7 @@ export const useCopilotStore = create()( // Abort in-progress tools and clear diff on new chat abortAllInProgressTools(set, get) try { - useWorkflowDiffStore.getState().clearDiff() + useWorkflowDiffStore.getState().clearDiff({ restoreBaseline: false }) } catch {} // Background-save the current chat before clearing (optimistic) @@ -2458,6 +2553,12 @@ export const useCopilotStore = create()( const userMessage = createUserMessage(message, fileAttachments, contexts, messageId) const streamingMessage = createStreamingMessage() + const snapshot = workflowId ? buildCheckpointWorkflowState(workflowId) : null + if (snapshot) { + set((state) => ({ + messageSnapshots: { ...state.messageSnapshots, [userMessage.id]: snapshot }, + })) + } let newMessages: CopilotMessage[] if (revertState) { @@ -2940,6 +3041,10 @@ export const useCopilotStore = create()( if (!workflowId) return set({ isRevertingCheckpoint: true, checkpointError: null }) try { + const { messageCheckpoints } = get() + const checkpointMessageId = Object.entries(messageCheckpoints).find(([, cps]) => + (cps || []).some((cp: any) => cp?.id === checkpointId) + )?.[0] const response = await fetch('/api/copilot/checkpoints/revert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -2985,6 +3090,11 @@ export const useCopilotStore = create()( }, }) } + if (checkpointMessageId) { + const { messageCheckpoints: currentCheckpoints } = get() + const updatedCheckpoints = { ...currentCheckpoints, [checkpointMessageId]: [] } + set({ messageCheckpoints: updatedCheckpoints }) + } set({ isRevertingCheckpoint: false }) } catch (error) { set({ @@ -2998,6 +3108,10 @@ export const useCopilotStore = create()( const { messageCheckpoints } = get() return messageCheckpoints[messageId] || [] }, + saveMessageCheckpoint: async (messageId: string) => { + if (!messageId) return false + return saveMessageCheckpoint(messageId, get, set) + }, // Handle streaming response handleStreamingResponse: async ( @@ -3192,21 +3306,33 @@ export const useCopilotStore = create()( const finalContentWithOptions = context.wasAborted && !context.suppressContinueOption ? appendContinueOption(finalContent) : finalContent - set((state) => ({ - messages: state.messages.map((msg) => - msg.id === assistantMessageId - ? { - ...msg, - content: finalContentWithOptions, - contentBlocks: sanitizedContentBlocks, - } - : msg - ), - isSendingMessage: false, - isAborting: false, - abortController: null, - currentUserMessageId: null, - })) + set((state) => { + const snapshotId = state.currentUserMessageId + const nextSnapshots = + snapshotId && state.messageSnapshots[snapshotId] + ? (() => { + const updated = { ...state.messageSnapshots } + delete updated[snapshotId] + return updated + })() + : state.messageSnapshots + return { + messages: state.messages.map((msg) => + msg.id === assistantMessageId + ? { + ...msg, + content: finalContentWithOptions, + contentBlocks: sanitizedContentBlocks, + } + : msg + ), + isSendingMessage: false, + isAborting: false, + abortController: null, + currentUserMessageId: null, + messageSnapshots: nextSnapshots, + } + }) if (context.newChatId && !get().currentChat) { await get().handleNewChatCreation(context.newChatId) diff --git a/apps/sim/stores/panel/copilot/types.ts b/apps/sim/stores/panel/copilot/types.ts index 05a4a6c126..d31ffc4b09 100644 --- a/apps/sim/stores/panel/copilot/types.ts +++ b/apps/sim/stores/panel/copilot/types.ts @@ -1,6 +1,7 @@ import type { CopilotMode, CopilotModelId } from '@/lib/copilot/models' export type { CopilotMode, CopilotModelId } from '@/lib/copilot/models' import type { ClientToolCallState, ClientToolDisplay } from '@/lib/copilot/tools/client/base-tool' +import type { WorkflowState } from '@/stores/workflows/workflow/types' export type ToolState = ClientToolCallState @@ -107,6 +108,7 @@ export interface CopilotState { checkpoints: any[] messageCheckpoints: Record + messageSnapshots: Record isLoading: boolean isLoadingChats: boolean @@ -195,6 +197,7 @@ export interface CopilotActions { loadMessageCheckpoints: (chatId: string) => Promise revertToCheckpoint: (checkpointId: string) => Promise getCheckpointsForMessage: (messageId: string) => any[] + saveMessageCheckpoint: (messageId: string) => Promise clearMessages: () => void clearError: () => void diff --git a/apps/sim/stores/workflow-diff/store.ts b/apps/sim/stores/workflow-diff/store.ts index c21247c823..acb3b1bcc6 100644 --- a/apps/sim/stores/workflow-diff/store.ts +++ b/apps/sim/stores/workflow-diff/store.ts @@ -23,6 +23,31 @@ import { const logger = createLogger('WorkflowDiffStore') const diffEngine = new WorkflowDiffEngine() +/** + * Detects when a diff contains no meaningful changes. + */ +function isEmptyDiffAnalysis(diffAnalysis?: { + new_blocks?: string[] + edited_blocks?: string[] + deleted_blocks?: string[] + field_diffs?: Record + edge_diff?: { new_edges?: string[]; deleted_edges?: string[] } +} | null): boolean { + if (!diffAnalysis) return false + const hasBlockChanges = + (diffAnalysis.new_blocks?.length || 0) > 0 || + (diffAnalysis.edited_blocks?.length || 0) > 0 || + (diffAnalysis.deleted_blocks?.length || 0) > 0 + const hasEdgeChanges = + (diffAnalysis.edge_diff?.new_edges?.length || 0) > 0 || + (diffAnalysis.edge_diff?.deleted_edges?.length || 0) > 0 + const hasFieldChanges = + Object.values(diffAnalysis.field_diffs || {}).some( + (diff) => (diff?.changed_fields?.length || 0) > 0 + ) + return !hasBlockChanges && !hasEdgeChanges && !hasFieldChanges +} + export const useWorkflowDiffStore = create()( devtools( (set, get) => { @@ -75,6 +100,24 @@ export const useWorkflowDiffStore = create + useCopilotStore.getState().saveMessageCheckpoint(triggerMessageId) + ) + .catch((error) => { + logger.warn('Failed to save checkpoint for diff', { error }) + }) + } + logger.info('Workflow diff applied optimistically', { workflowId: activeWorkflowId, blocks: Object.keys(candidateState.blocks || {}).length, From c22d6723bbbecd3edfd4d1a7556725be1091a5e9 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 15 Jan 2026 15:06:01 -0800 Subject: [PATCH 05/13] Fix thinking tags --- apps/sim/stores/panel/copilot/store.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index b07bc0a5b9..55b983cbba 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -1001,14 +1001,23 @@ function beginThinkingBlock(context: StreamingContext) { context.currentTextBlock = null } +/** + * Removes thinking tags from streamed content. + */ +function stripThinkingTags(text: string): string { + return text.replace(/<\/?thinking>/g, '') +} + function appendThinkingContent(context: StreamingContext, text: string) { if (!text) return + const cleanedText = stripThinkingTags(text) + if (!cleanedText) return if (context.currentThinkingBlock) { - context.currentThinkingBlock.content += text + context.currentThinkingBlock.content += cleanedText } else { context.currentThinkingBlock = contentBlockPool.get() context.currentThinkingBlock.type = THINKING_BLOCK_TYPE - context.currentThinkingBlock.content = text + context.currentThinkingBlock.content = cleanedText context.currentThinkingBlock.timestamp = Date.now() context.currentThinkingBlock.startTime = Date.now() context.contentBlocks.push(context.currentThinkingBlock) From 33953962893b7bff9b9b6f588af9ad0661ed21d0 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 15 Jan 2026 15:10:42 -0800 Subject: [PATCH 06/13] Fix thinking text --- .../components/thinking-block.tsx | 25 ++++++++++++++----- .../tools/client/blocks/get-block-options.ts | 11 +++++--- apps/sim/stores/panel/copilot/store.ts | 6 +++-- 3 files changed, 31 insertions(+), 11 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 ec765dd153..835cae104f 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 @@ -1,10 +1,20 @@ 'use client' -import { memo, useEffect, useRef, useState } from 'react' +import { memo, useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import { ChevronUp } from 'lucide-react' import CopilotMarkdownRenderer from './markdown-renderer' +/** + * Removes thinking tags (raw or escaped) from streamed content. + */ +function stripThinkingTags(text: string): string { + return text + .replace(/<\/?thinking[^>]*>/gi, '') + .replace(/<\/?thinking[^&]*>/gi, '') + .trim() +} + /** * Max height for thinking content before internal scrolling kicks in */ @@ -187,6 +197,9 @@ export function ThinkingBlock({ label = 'Thought', hasSpecialTags = false, }: ThinkingBlockProps) { + // Strip thinking tags from content on render to handle persisted messages + const cleanContent = useMemo(() => stripThinkingTags(content || ''), [content]) + const [isExpanded, setIsExpanded] = useState(false) const [duration, setDuration] = useState(0) const [userHasScrolledAway, setUserHasScrolledAway] = useState(false) @@ -209,10 +222,10 @@ export function ThinkingBlock({ return } - if (!userCollapsedRef.current && content && content.trim().length > 0) { + if (!userCollapsedRef.current && cleanContent && cleanContent.length > 0) { setIsExpanded(true) } - }, [isStreaming, content, hasFollowingContent, hasSpecialTags]) + }, [isStreaming, cleanContent, hasFollowingContent, hasSpecialTags]) // Reset start time when streaming begins useEffect(() => { @@ -298,7 +311,7 @@ export function ThinkingBlock({ return `${seconds}s` } - const hasContent = content && content.trim().length > 0 + const hasContent = cleanContent.length > 0 // Thinking is "done" when streaming ends OR when there's following content (like a tool call) OR when special tags appear const isThinkingDone = !isStreaming || hasFollowingContent || hasSpecialTags const durationText = `${label} for ${formatDuration(duration)}` @@ -374,7 +387,7 @@ export function ThinkingBlock({ isExpanded ? 'mt-1.5 max-h-[150px] opacity-100' : 'max-h-0 opacity-0' )} > - + ) @@ -412,7 +425,7 @@ export function ThinkingBlock({ > {/* Completed thinking text - dimmed with markdown */}
- +
diff --git a/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts b/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts index a104688e5f..25fb19939b 100644 --- a/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts +++ b/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts @@ -37,10 +37,15 @@ export class GetBlockOptionsClientTool extends BaseClientTool { }, }, getDynamicText: (params, state) => { - if (params?.blockId && typeof params.blockId === 'string') { + const blockId = + (params as any)?.blockId || + (params as any)?.blockType || + (params as any)?.block_id || + (params as any)?.block_type + if (typeof blockId === 'string') { // Look up the block config to get the human-readable name - const blockConfig = getBlock(params.blockId) - const blockName = (blockConfig?.name ?? params.blockId.replace(/_/g, ' ')).toLowerCase() + const blockConfig = getBlock(blockId) + const blockName = (blockConfig?.name ?? blockId.replace(/_/g, ' ')).toLowerCase() switch (state) { case ClientToolCallState.success: diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index 55b983cbba..201a3ee266 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -1002,10 +1002,12 @@ function beginThinkingBlock(context: StreamingContext) { } /** - * Removes thinking tags from streamed content. + * Removes thinking tags (raw or escaped) from streamed content. */ function stripThinkingTags(text: string): string { - return text.replace(/<\/?thinking>/g, '') + return text + .replace(/<\/?thinking[^>]*>/gi, '') + .replace(/<\/?thinking[^&]*>/gi, '') } function appendThinkingContent(context: StreamingContext, text: string) { From d2cfb626a1176501b11d98d472ae0a808b0cd427 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 15 Jan 2026 15:12:08 -0800 Subject: [PATCH 07/13] Fix get block options text --- .../tools/client/blocks/get-block-options.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts b/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts index 25fb19939b..f830bed84e 100644 --- a/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts +++ b/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts @@ -25,14 +25,14 @@ export class GetBlockOptionsClientTool extends BaseClientTool { static readonly metadata: BaseClientToolMetadata = { displayNames: { - [ClientToolCallState.generating]: { text: 'Getting block options', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Getting block options', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Getting block options', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Retrieved block options', icon: ListFilter }, - [ClientToolCallState.error]: { text: 'Failed to get block options', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted getting block options', icon: XCircle }, + [ClientToolCallState.generating]: { text: 'Getting block operations', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Getting block operations', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Getting block operations', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Retrieved block operations', icon: ListFilter }, + [ClientToolCallState.error]: { text: 'Failed to get block operations', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Aborted getting block operations', icon: XCircle }, [ClientToolCallState.rejected]: { - text: 'Skipped getting block options', + text: 'Skipped getting block operations', icon: MinusCircle, }, }, @@ -49,17 +49,17 @@ export class GetBlockOptionsClientTool extends BaseClientTool { switch (state) { case ClientToolCallState.success: - return `Retrieved ${blockName} options` + return `Retrieved ${blockName} operations` case ClientToolCallState.executing: case ClientToolCallState.generating: case ClientToolCallState.pending: - return `Retrieving ${blockName} options` + return `Retrieving ${blockName} operations` case ClientToolCallState.error: - return `Failed to retrieve ${blockName} options` + return `Failed to retrieve ${blockName} operations` case ClientToolCallState.aborted: - return `Aborted retrieving ${blockName} options` + return `Aborted retrieving ${blockName} operations` case ClientToolCallState.rejected: - return `Skipped retrieving ${blockName} options` + return `Skipped retrieving ${blockName} operations` } } return undefined From 4c30d99e5a2fefb1221308d69ff4d9bc79a4a2b9 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 15 Jan 2026 15:43:26 -0800 Subject: [PATCH 08/13] Fix bugs --- .../api/workflows/[id]/chat/status/route.ts | 14 +++ .../copilot-message/copilot-message.tsx | 9 +- .../hooks/use-checkpoint-management.ts | 18 +++- .../copilot/tools/client/init-tool-configs.ts | 1 + .../workflow/check-deployment-status.ts | 28 ++++++ .../tools/client/workflow/deploy-chat.ts | 92 +++++++++++-------- .../tools/client/workflow/deploy-mcp.ts | 45 ++++++++- .../copilot/tools/client/workflow/redeploy.ts | 65 +++++++++++++ apps/sim/stores/panel/copilot/store.ts | 30 +++++- 9 files changed, 251 insertions(+), 51 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/client/workflow/redeploy.ts diff --git a/apps/sim/app/api/workflows/[id]/chat/status/route.ts b/apps/sim/app/api/workflows/[id]/chat/status/route.ts index f7733e1407..1bd930b7ef 100644 --- a/apps/sim/app/api/workflows/[id]/chat/status/route.ts +++ b/apps/sim/app/api/workflows/[id]/chat/status/route.ts @@ -22,6 +22,13 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: .select({ id: chat.id, identifier: chat.identifier, + title: chat.title, + description: chat.description, + customizations: chat.customizations, + authType: chat.authType, + allowedEmails: chat.allowedEmails, + outputConfigs: chat.outputConfigs, + password: chat.password, isActive: chat.isActive, }) .from(chat) @@ -34,6 +41,13 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: ? { id: deploymentResults[0].id, identifier: deploymentResults[0].identifier, + title: deploymentResults[0].title, + description: deploymentResults[0].description, + customizations: deploymentResults[0].customizations, + authType: deploymentResults[0].authType, + allowedEmails: deploymentResults[0].allowedEmails, + outputConfigs: deploymentResults[0].outputConfigs, + hasPassword: Boolean(deploymentResults[0].password), } : null 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 be3af2f886..d62f6fc108 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,6 +1,6 @@ 'use client' -import { type FC, memo, useCallback, useMemo, useState } from 'react' +import { type FC, memo, useCallback, useMemo, useRef, useState } from 'react' import { RotateCcw } from 'lucide-react' import { Button } from '@/components/emcn' import { @@ -93,6 +93,8 @@ const CopilotMessage: FC = memo( // UI state const [isHoveringMessage, setIsHoveringMessage] = useState(false) + const cancelEditRef = useRef<(() => void) | null>(null) + // Checkpoint management hook const { showRestoreConfirmation, @@ -112,7 +114,8 @@ const CopilotMessage: FC = memo( messages, messageCheckpoints, onRevertModeChange, - onEditModeChange + onEditModeChange, + () => cancelEditRef.current?.() ) // Message editing hook @@ -142,6 +145,8 @@ const CopilotMessage: FC = memo( pendingEditRef, }) + cancelEditRef.current = handleCancelEdit + // Get clean text content with double newline parsing const cleanTextContent = useMemo(() => { if (!message.content) return '' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks/use-checkpoint-management.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks/use-checkpoint-management.ts index b7dfafe957..dadb895d16 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks/use-checkpoint-management.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks/use-checkpoint-management.ts @@ -22,7 +22,8 @@ export function useCheckpointManagement( messages: CopilotMessage[], messageCheckpoints: any[], onRevertModeChange?: (isReverting: boolean) => void, - onEditModeChange?: (isEditing: boolean) => void + onEditModeChange?: (isEditing: boolean) => void, + onCancelEdit?: () => void ) { const [showRestoreConfirmation, setShowRestoreConfirmation] = useState(false) const [showCheckpointDiscardModal, setShowCheckpointDiscardModal] = useState(false) @@ -154,6 +155,8 @@ export function useCheckpointManagement( } setShowCheckpointDiscardModal(false) + onEditModeChange?.(false) + onCancelEdit?.() const { sendMessage } = useCopilotStore.getState() if (pendingEditRef.current) { @@ -180,13 +183,22 @@ export function useCheckpointManagement( } finally { setIsProcessingDiscard(false) } - }, [messageCheckpoints, revertToCheckpoint, message, messages]) + }, [ + messageCheckpoints, + revertToCheckpoint, + message, + messages, + onEditModeChange, + onCancelEdit, + ]) /** * Cancels checkpoint discard and clears pending edit */ const handleCancelCheckpointDiscard = useCallback(() => { setShowCheckpointDiscardModal(false) + onEditModeChange?.(false) + onCancelEdit?.() pendingEditRef.current = null }, []) @@ -218,7 +230,7 @@ export function useCheckpointManagement( } pendingEditRef.current = null } - }, [message, messages]) + }, [message, messages, onEditModeChange, onCancelEdit]) /** * Handles keyboard events for restore confirmation (Escape/Enter) 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 b2d480f037..9850c65943 100644 --- a/apps/sim/lib/copilot/tools/client/init-tool-configs.ts +++ b/apps/sim/lib/copilot/tools/client/init-tool-configs.ts @@ -28,6 +28,7 @@ import './workflow/deploy-api' import './workflow/deploy-chat' import './workflow/deploy-mcp' import './workflow/edit-workflow' +import './workflow/redeploy' import './workflow/run-workflow' import './workflow/set-global-workflow-variables' diff --git a/apps/sim/lib/copilot/tools/client/workflow/check-deployment-status.ts b/apps/sim/lib/copilot/tools/client/workflow/check-deployment-status.ts index e2346a4c72..a0d3de72e4 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/check-deployment-status.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/check-deployment-status.ts @@ -15,6 +15,8 @@ interface ApiDeploymentDetails { isDeployed: boolean deployedAt: string | null endpoint: string | null + apiKey: string | null + needsRedeployment: boolean } interface ChatDeploymentDetails { @@ -22,6 +24,14 @@ interface ChatDeploymentDetails { chatId: string | null identifier: string | null chatUrl: string | null + title: string | null + description: string | null + authType: string | null + allowedEmails: string[] | null + outputConfigs: Array<{ blockId: string; path: string }> | null + welcomeMessage: string | null + primaryColor: string | null + hasPassword: boolean } interface McpDeploymentDetails { @@ -31,6 +41,8 @@ interface McpDeploymentDetails { serverName: string toolName: string toolDescription: string | null + parameterSchema?: Record | null + toolId?: string | null }> } @@ -96,6 +108,8 @@ export class CheckDeploymentStatusClientTool extends BaseClientTool { isDeployed: isApiDeployed, deployedAt: apiDeploy?.deployedAt || null, endpoint: isApiDeployed ? `${appUrl}/api/workflows/${workflowId}/execute` : null, + apiKey: apiDeploy?.apiKey || null, + needsRedeployment: apiDeploy?.needsRedeployment === true, } // Chat deployment details @@ -105,6 +119,18 @@ export class CheckDeploymentStatusClientTool extends BaseClientTool { chatId: chatDeploy?.deployment?.id || null, identifier: chatDeploy?.deployment?.identifier || null, chatUrl: isChatDeployed ? `${appUrl}/chat/${chatDeploy?.deployment?.identifier}` : null, + title: chatDeploy?.deployment?.title || null, + description: chatDeploy?.deployment?.description || null, + authType: chatDeploy?.deployment?.authType || null, + allowedEmails: Array.isArray(chatDeploy?.deployment?.allowedEmails) + ? chatDeploy?.deployment?.allowedEmails + : null, + outputConfigs: Array.isArray(chatDeploy?.deployment?.outputConfigs) + ? chatDeploy?.deployment?.outputConfigs + : null, + welcomeMessage: chatDeploy?.deployment?.customizations?.welcomeMessage || null, + primaryColor: chatDeploy?.deployment?.customizations?.primaryColor || null, + hasPassword: chatDeploy?.deployment?.hasPassword === true, } // MCP deployment details - find servers that have this workflow as a tool @@ -129,6 +155,8 @@ export class CheckDeploymentStatusClientTool extends BaseClientTool { serverName: server.name, toolName: tool.toolName, toolDescription: tool.toolDescription, + parameterSchema: tool.parameterSchema ?? null, + toolId: tool.id ?? null, }) } } 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 be08d72a35..73ea43af9a 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/deploy-chat.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/deploy-chat.ts @@ -208,54 +208,70 @@ export class DeployChatClientTool extends BaseClientTool { return } - // Deploy action - validate required fields - if (!args?.identifier && !workflow?.name) { - throw new Error('Either identifier or workflow name is required') - } + this.setState(ClientToolCallState.executing) - if (!args?.title && !workflow?.name) { - throw new Error('Chat title is required') + const statusRes = await fetch(`/api/workflows/${workflowId}/chat/status`) + const statusJson = statusRes.ok ? await statusRes.json() : null + const existingDeployment = statusJson?.deployment || null + + const baseIdentifier = + existingDeployment?.identifier || this.generateIdentifier(workflow?.name || 'chat') + const baseTitle = existingDeployment?.title || workflow?.name || 'Chat' + const baseDescription = existingDeployment?.description || '' + const baseAuthType = existingDeployment?.authType || 'public' + const baseWelcomeMessage = + existingDeployment?.customizations?.welcomeMessage || 'Hi there! How can I help you today?' + const basePrimaryColor = + existingDeployment?.customizations?.primaryColor || 'var(--brand-primary-hover-hex)' + const baseAllowedEmails = Array.isArray(existingDeployment?.allowedEmails) + ? existingDeployment.allowedEmails + : [] + const baseOutputConfigs = Array.isArray(existingDeployment?.outputConfigs) + ? existingDeployment.outputConfigs + : [] + + const identifier = args?.identifier || baseIdentifier + const title = args?.title || baseTitle + const description = args?.description ?? baseDescription + const authType = args?.authType || baseAuthType + const welcomeMessage = args?.welcomeMessage || baseWelcomeMessage + const outputConfigs = args?.outputConfigs || baseOutputConfigs + const allowedEmails = args?.allowedEmails || baseAllowedEmails + const primaryColor = basePrimaryColor + + if (!identifier || !title) { + throw new Error('Chat identifier and title are required') } - const identifier = args?.identifier || this.generateIdentifier(workflow?.name || 'chat') - const title = args?.title || workflow?.name || 'Chat' - const description = args?.description || '' - const authType = args?.authType || 'public' - const welcomeMessage = args?.welcomeMessage || 'Hi there! How can I help you today?' - - // Validate auth-specific requirements - if (authType === 'password' && !args?.password) { + if (authType === 'password' && !args?.password && !existingDeployment?.hasPassword) { throw new Error('Password is required when using password protection') } - if ( - (authType === 'email' || authType === 'sso') && - (!args?.allowedEmails || args.allowedEmails.length === 0) - ) { + if ((authType === 'email' || authType === 'sso') && allowedEmails.length === 0) { throw new Error(`At least one email or domain is required when using ${authType} access`) } - this.setState(ClientToolCallState.executing) - - const outputConfigs = args?.outputConfigs || [] - const payload = { workflowId, identifier: identifier.trim(), title: title.trim(), description: description.trim(), customizations: { - primaryColor: 'var(--brand-primary-hover-hex)', + primaryColor, welcomeMessage: welcomeMessage.trim(), }, authType, password: authType === 'password' ? args?.password : undefined, - allowedEmails: authType === 'email' || authType === 'sso' ? args?.allowedEmails : [], + allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [], outputConfigs, } - const res = await fetch('/api/chat', { - method: 'POST', + const isUpdating = Boolean(existingDeployment?.id) + const endpoint = isUpdating ? `/api/chat/manage/${existingDeployment.id}` : '/api/chat' + const method = isUpdating ? 'PATCH' : 'POST' + + const res = await fetch(endpoint, { + method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) @@ -265,18 +281,18 @@ export class DeployChatClientTool extends BaseClientTool { if (!res.ok) { if (json.error === 'Identifier already in use') { this.setState(ClientToolCallState.error) - await this.markToolComplete( - 400, - `The identifier "${identifier}" is already in use. Please choose a different one.`, - { - success: false, - action: 'deploy', - isDeployed: false, - identifier, - error: `Identifier "${identifier}" is already taken`, - errorCode: 'IDENTIFIER_TAKEN', - } - ) + await this.markToolComplete( + 400, + `The identifier "${identifier}" is already in use. Please choose a different one.`, + { + success: false, + action: 'deploy', + isDeployed: false, + identifier, + error: `Identifier "${identifier}" is already taken`, + errorCode: 'IDENTIFIER_TAKEN', + } + ) return } 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 080498473c..bcd87fc252 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/deploy-mcp.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/deploy-mcp.ts @@ -128,7 +128,6 @@ export class DeployMcpClientTool extends BaseClientTool { this.setState(ClientToolCallState.executing) - // Build parameter schema with descriptions if provided let parameterSchema: Record | undefined if (args?.parameterDescriptions && args.parameterDescriptions.length > 0) { const properties: Record = {} @@ -155,9 +154,49 @@ export class DeployMcpClientTool extends BaseClientTool { const data = await res.json() if (!res.ok) { - // Handle specific error cases if (data.error?.includes('already added')) { - throw new Error('This workflow is already deployed to this MCP server') + const toolsRes = await fetch( + `/api/mcp/workflow-servers/${args.serverId}/tools?workspaceId=${workspaceId}` + ) + const toolsJson = toolsRes.ok ? await toolsRes.json() : null + const tools = toolsJson?.data?.tools || [] + const existingTool = tools.find((tool: any) => tool.workflowId === workflowId) + if (!existingTool?.id) { + throw new Error('This workflow is already deployed to this MCP server') + } + const patchRes = await fetch( + `/api/mcp/workflow-servers/${args.serverId}/tools/${existingTool.id}?workspaceId=${workspaceId}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + toolName: args.toolName?.trim(), + toolDescription: args.toolDescription?.trim(), + parameterSchema, + }), + } + ) + const patchJson = patchRes.ok ? await patchRes.json() : null + if (!patchRes.ok) { + const patchError = patchJson?.error || `Failed to update MCP tool (${patchRes.status})` + throw new Error(patchError) + } + const updatedTool = patchJson?.data?.tool + this.setState(ClientToolCallState.success) + await this.markToolComplete( + 200, + `Workflow MCP tool updated to "${updatedTool?.toolName || existingTool.toolName}".`, + { + success: true, + toolId: updatedTool?.id || existingTool.id, + toolName: updatedTool?.toolName || existingTool.toolName, + toolDescription: updatedTool?.toolDescription || existingTool.toolDescription, + serverId: args.serverId, + updated: true, + } + ) + logger.info('Updated workflow MCP tool', { toolId: existingTool.id }) + return } if (data.error?.includes('not deployed')) { throw new Error('Workflow must be deployed before adding as an MCP tool') diff --git a/apps/sim/lib/copilot/tools/client/workflow/redeploy.ts b/apps/sim/lib/copilot/tools/client/workflow/redeploy.ts new file mode 100644 index 0000000000..602b87d3af --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/workflow/redeploy.ts @@ -0,0 +1,65 @@ +import { createLogger } from '@sim/logger' +import { Loader2, Rocket, XCircle } from 'lucide-react' +import { + BaseClientTool, + type BaseClientToolMetadata, + ClientToolCallState, +} from '@/lib/copilot/tools/client/base-tool' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +export class RedeployClientTool extends BaseClientTool { + static readonly id = 'redeploy' + + constructor(toolCallId: string) { + super(toolCallId, RedeployClientTool.id, RedeployClientTool.metadata) + } + + static readonly metadata: BaseClientToolMetadata = { + displayNames: { + [ClientToolCallState.generating]: { text: 'Redeploying workflow', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Redeploy workflow', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Redeploying workflow', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Redeployed workflow', icon: Rocket }, + [ClientToolCallState.error]: { text: 'Failed to redeploy workflow', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Aborted redeploy', icon: XCircle }, + [ClientToolCallState.rejected]: { text: 'Skipped redeploy', icon: XCircle }, + }, + interrupt: undefined, + } + + async execute(): Promise { + const logger = createLogger('RedeployClientTool') + try { + this.setState(ClientToolCallState.executing) + + const { activeWorkflowId } = useWorkflowRegistry.getState() + if (!activeWorkflowId) { + throw new Error('No workflow ID provided') + } + + const res = await fetch(`/api/workflows/${activeWorkflowId}/deploy`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deployChatEnabled: false }), + }) + + const json = await res.json().catch(() => ({})) + if (!res.ok) { + const errorText = json?.error || `Server error (${res.status})` + throw new Error(errorText) + } + + this.setState(ClientToolCallState.success) + await this.markToolComplete(200, 'Workflow redeployed', { + workflowId: activeWorkflowId, + deployedAt: json?.deployedAt || null, + schedule: json?.schedule, + }) + } catch (error: any) { + logger.error('Redeploy failed', { message: error?.message }) + this.setState(ClientToolCallState.error) + await this.markToolComplete(500, error?.message || 'Failed to redeploy workflow') + } + } +} + diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index 201a3ee266..5a68c81dd8 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -72,6 +72,7 @@ import { ListUserWorkflowsClientTool } from '@/lib/copilot/tools/client/workflow 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 { RedeployClientTool } from '@/lib/copilot/tools/client/workflow/redeploy' import { RunWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/run-workflow' import { SetGlobalWorkflowVariablesClientTool } from '@/lib/copilot/tools/client/workflow/set-global-workflow-variables' import { getQueryClient } from '@/app/_shell/providers/query-provider' @@ -150,6 +151,7 @@ const CLIENT_TOOL_INSTANTIATORS: Record any> = { deploy_api: (id) => new DeployApiClientTool(id), deploy_chat: (id) => new DeployChatClientTool(id), deploy_mcp: (id) => new DeployMcpClientTool(id), + redeploy: (id) => new RedeployClientTool(id), list_workspace_mcp_servers: (id) => new ListWorkspaceMcpServersClientTool(id), create_workspace_mcp_server: (id) => new CreateWorkspaceMcpServerClientTool(id), check_deployment_status: (id) => new CheckDeploymentStatusClientTool(id), @@ -212,6 +214,7 @@ export const CLASS_TOOL_METADATA: Record()( // Send a message (streaming only) sendMessage: async (message: string, options = {}) => { - const { workflowId, currentChat, mode, revertState, isSendingMessage } = get() + const { + workflowId, + currentChat, + mode, + revertState, + isSendingMessage, + abortController: activeAbortController, + } = get() const { stream = true, fileAttachments, @@ -2550,7 +2560,17 @@ export const useCopilotStore = create()( if (!workflowId) return // If already sending a message, queue this one instead - if (isSendingMessage) { + if (isSendingMessage && !activeAbortController) { + logger.warn('[Copilot] sendMessage: stale sending state detected, clearing', { + originalMessageId: messageId, + }) + set({ isSendingMessage: false }) + } else if (isSendingMessage && activeAbortController?.signal.aborted) { + logger.warn('[Copilot] sendMessage: aborted controller detected, clearing', { + originalMessageId: messageId, + }) + set({ isSendingMessage: false, abortController: null }) + } else if (isSendingMessage) { get().addToQueue(message, { fileAttachments, contexts, messageId }) logger.info('[Copilot] Message queued (already sending)', { queueLength: get().messageQueue.length + 1, @@ -2559,8 +2579,8 @@ export const useCopilotStore = create()( return } - const abortController = new AbortController() - set({ isSendingMessage: true, error: null, abortController }) + const nextAbortController = new AbortController() + set({ isSendingMessage: true, error: null, abortController: nextAbortController }) const userMessage = createUserMessage(message, fileAttachments, contexts, messageId) const streamingMessage = createStreamingMessage() @@ -2656,7 +2676,7 @@ export const useCopilotStore = create()( fileAttachments, contexts: filteredContexts, commands: commands?.length ? commands : undefined, - abortSignal: abortController.signal, + abortSignal: nextAbortController.signal, }) if (result.success && result.stream) { From c072fc2b18d32ea02166ec4ca96808b43cb3f788 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 15 Jan 2026 15:53:26 -0800 Subject: [PATCH 09/13] Fix redeploy --- apps/sim/lib/copilot/tools/client/workflow/redeploy.ts | 7 +++++++ apps/sim/stores/panel/copilot/store.ts | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/apps/sim/lib/copilot/tools/client/workflow/redeploy.ts b/apps/sim/lib/copilot/tools/client/workflow/redeploy.ts index 602b87d3af..01029f4039 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/redeploy.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/redeploy.ts @@ -9,6 +9,7 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store' export class RedeployClientTool extends BaseClientTool { static readonly id = 'redeploy' + private hasExecuted = false constructor(toolCallId: string) { super(toolCallId, RedeployClientTool.id, RedeployClientTool.metadata) @@ -30,6 +31,12 @@ export class RedeployClientTool extends BaseClientTool { async execute(): Promise { const logger = createLogger('RedeployClientTool') try { + if (this.hasExecuted) { + logger.info('execute skipped (already executed)', { toolCallId: this.toolCallId }) + return + } + this.hasExecuted = true + this.setState(ClientToolCallState.executing) const { activeWorkflowId } = useWorkflowRegistry.getState() diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index 5a68c81dd8..172b4402d2 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -1307,6 +1307,11 @@ const sseHandlers: Record = { updateStreamingMessage(set, context) + // Do not execute on partial tool_call frames + if (isPartial) { + return + } + // Prefer interface-based registry to determine interrupt and execute try { const def = name ? getTool(name) : undefined @@ -1892,6 +1897,7 @@ const subAgentSSEHandlers: Record = { const id: string | undefined = toolData.id || data?.toolCallId const name: string | undefined = toolData.name || data?.toolName if (!id || !name) return + const isPartial = toolData.partial === true // Arguments can come in different locations depending on SSE format // Check multiple possible locations @@ -1958,6 +1964,10 @@ const subAgentSSEHandlers: Record = { updateToolCallWithSubAgentData(context, get, set, parentToolCallId) + if (isPartial) { + return + } + // Execute client tools in parallel (non-blocking) - same pattern as main tool_call handler try { const def = getTool(name) From 5df7ac420765544a9143f6018e8942ffb7ad1743 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 16 Jan 2026 10:37:58 -0800 Subject: [PATCH 10/13] Fix loading indicators --- apps/sim/app/api/copilot/chat/route.ts | 2 +- .../api/copilot/chat/update-messages/route.ts | 2 +- .../components/smooth-streaming.tsx | 14 ++++++-- .../components/thinking-block.tsx | 5 ++- .../copilot-message/copilot-message.tsx | 5 +-- .../hooks/use-checkpoint-management.ts | 9 +---- .../hooks/use-context-management.ts | 4 ++- .../components/user-input/user-input.tsx | 10 ++++-- apps/sim/lib/copilot/api.ts | 6 +--- apps/sim/lib/copilot/models.ts | 1 - .../tools/client/workflow/deploy-chat.ts | 24 ++++++------- .../copilot/tools/client/workflow/redeploy.ts | 1 - apps/sim/stores/panel/copilot/store.ts | 35 +++++++++++-------- apps/sim/stores/panel/copilot/types.ts | 2 ++ apps/sim/stores/workflow-diff/store.ts | 23 ++++++------ 15 files changed, 80 insertions(+), 63 deletions(-) diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index a4c845b461..9d31bf5c36 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -7,8 +7,8 @@ import { z } from 'zod' import { getSession } from '@/lib/auth' import { generateChatTitle } from '@/lib/copilot/chat-title' import { getCopilotModel } from '@/lib/copilot/config' -import { COPILOT_MODEL_IDS, COPILOT_REQUEST_MODES } from '@/lib/copilot/models' import { SIM_AGENT_API_URL_DEFAULT, SIM_AGENT_VERSION } from '@/lib/copilot/constants' +import { COPILOT_MODEL_IDS, COPILOT_REQUEST_MODES } from '@/lib/copilot/models' import { authenticateCopilotRequestSessionOnly, createBadRequestResponse, 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 cc38bfbb63..4eceb7ea4b 100644 --- a/apps/sim/app/api/copilot/chat/update-messages/route.ts +++ b/apps/sim/app/api/copilot/chat/update-messages/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { COPILOT_MODES } from '@/lib/copilot/models' import { authenticateCopilotRequestSessionOnly, createInternalServerErrorResponse, @@ -11,7 +12,6 @@ import { createRequestTracker, createUnauthorizedResponse, } from '@/lib/copilot/request-helpers' -import { COPILOT_MODES } from '@/lib/copilot/models' const logger = createLogger('CopilotChatUpdateAPI') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming.tsx index c55a60e736..62a9ae6ba6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming.tsx @@ -1,4 +1,5 @@ import { memo, useEffect, useRef, useState } from 'react' +import { cn } from '@/lib/core/utils/cn' import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer' /** @@ -6,14 +7,23 @@ import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId */ const CHARACTER_DELAY = 3 +/** + * Props for the StreamingIndicator component + */ +interface StreamingIndicatorProps { + /** Optional class name for layout adjustments */ + className?: string +} + /** * StreamingIndicator shows animated dots during message streaming * Used as a standalone indicator when no content has arrived yet * + * @param props - Component props * @returns Animated loading indicator */ -export const StreamingIndicator = memo(() => ( -
+export const StreamingIndicator = memo(({ className }: StreamingIndicatorProps) => ( +
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 835cae104f..2b5b023362 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 @@ -387,7 +387,10 @@ export function ThinkingBlock({ isExpanded ? 'mt-1.5 max-h-[150px] opacity-100' : 'max-h-0 opacity-0' )} > - +
) 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 d62f6fc108..821ee7b706 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 @@ -493,8 +493,9 @@ const CopilotMessage: FC = memo( {/* Content blocks in chronological order */} {memoizedContentBlocks} - {/* Streaming indicator always at bottom during streaming */} - {isStreaming && } + {isStreaming && ( + + )} {message.errorType === 'usage_limit' && (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks/use-checkpoint-management.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks/use-checkpoint-management.ts index dadb895d16..734cbd11a3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks/use-checkpoint-management.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks/use-checkpoint-management.ts @@ -183,14 +183,7 @@ export function useCheckpointManagement( } finally { setIsProcessingDiscard(false) } - }, [ - messageCheckpoints, - revertToCheckpoint, - message, - messages, - onEditModeChange, - onCancelEdit, - ]) + }, [messageCheckpoints, revertToCheckpoint, message, messages, onEditModeChange, onCancelEdit]) /** * Cancels checkpoint discard and clears pending edit diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts index 0dd4f3f16b..90b5d6bc99 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts @@ -81,7 +81,9 @@ export function useContextManagement({ message, initialContexts }: UseContextMan // Check for slash command tokens or mention tokens based on kind const isSlashCommand = c.kind === 'slash_command' const prefix = isSlashCommand ? '/' : '@' - const tokenPattern = new RegExp(`(^|\\s)${escapeRegex(prefix)}${escapeRegex(c.label)}(\\s|$)`) + const tokenPattern = new RegExp( + `(^|\\s)${escapeRegex(prefix)}${escapeRegex(c.label)}(\\s|$)` + ) return tokenPattern.test(message) }) return filtered.length === prev.length ? prev : filtered 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 50c0d45697..b182634164 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 @@ -735,7 +735,10 @@ const UserInput = forwardRef( variant='outline' onClick={handleOpenMentionMenuWithAt} title='Insert @' - className={cn('cursor-pointer rounded-[6px] p-[4.5px]', disabled && 'cursor-not-allowed')} + className={cn( + 'cursor-pointer rounded-[6px] p-[4.5px]', + disabled && 'cursor-not-allowed' + )} > @@ -744,7 +747,10 @@ const UserInput = forwardRef( variant='outline' onClick={handleOpenSlashMenu} title='Insert /' - className={cn('cursor-pointer rounded-[6px] p-[4.5px]', disabled && 'cursor-not-allowed')} + className={cn( + 'cursor-pointer rounded-[6px] p-[4.5px]', + disabled && 'cursor-not-allowed' + )} > / diff --git a/apps/sim/lib/copilot/api.ts b/apps/sim/lib/copilot/api.ts index eb5e3e95ae..c680f9751c 100644 --- a/apps/sim/lib/copilot/api.ts +++ b/apps/sim/lib/copilot/api.ts @@ -1,9 +1,5 @@ import { createLogger } from '@sim/logger' -import type { - CopilotMode, - CopilotModelId, - CopilotTransportMode, -} from '@/lib/copilot/models' +import type { CopilotMode, CopilotModelId, CopilotTransportMode } from '@/lib/copilot/models' const logger = createLogger('CopilotAPI') diff --git a/apps/sim/lib/copilot/models.ts b/apps/sim/lib/copilot/models.ts index 20b30a68b7..83a90169be 100644 --- a/apps/sim/lib/copilot/models.ts +++ b/apps/sim/lib/copilot/models.ts @@ -33,4 +33,3 @@ export type CopilotTransportMode = (typeof COPILOT_TRANSPORT_MODES)[number] export const COPILOT_REQUEST_MODES = ['ask', 'build', 'plan', 'agent'] as const export type CopilotRequestMode = (typeof COPILOT_REQUEST_MODES)[number] - 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 73ea43af9a..24ad19a53b 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/deploy-chat.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/deploy-chat.ts @@ -281,18 +281,18 @@ export class DeployChatClientTool extends BaseClientTool { if (!res.ok) { if (json.error === 'Identifier already in use') { this.setState(ClientToolCallState.error) - await this.markToolComplete( - 400, - `The identifier "${identifier}" is already in use. Please choose a different one.`, - { - success: false, - action: 'deploy', - isDeployed: false, - identifier, - error: `Identifier "${identifier}" is already taken`, - errorCode: 'IDENTIFIER_TAKEN', - } - ) + await this.markToolComplete( + 400, + `The identifier "${identifier}" is already in use. Please choose a different one.`, + { + success: false, + action: 'deploy', + isDeployed: false, + identifier, + error: `Identifier "${identifier}" is already taken`, + errorCode: 'IDENTIFIER_TAKEN', + } + ) return } diff --git a/apps/sim/lib/copilot/tools/client/workflow/redeploy.ts b/apps/sim/lib/copilot/tools/client/workflow/redeploy.ts index 01029f4039..2fef023fb7 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/redeploy.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/redeploy.ts @@ -69,4 +69,3 @@ export class RedeployClientTool extends BaseClientTool { } } } - diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index 172b4402d2..67d12aa192 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -978,7 +978,10 @@ function appendContinueOption(content: string): string { function appendContinueOptionBlock(blocks: any[]): any[] { if (!Array.isArray(blocks)) return blocks const hasOptions = blocks.some( - (block) => block?.type === TEXT_BLOCK_TYPE && typeof block.content === 'string' && //i.test(block.content) + (block) => + block?.type === TEXT_BLOCK_TYPE && + typeof block.content === 'string' && + //i.test(block.content) ) if (hasOptions) return blocks return [ @@ -1008,9 +1011,7 @@ function beginThinkingBlock(context: StreamingContext) { * Removes thinking tags (raw or escaped) from streamed content. */ function stripThinkingTags(text: string): string { - return text - .replace(/<\/?thinking[^>]*>/gi, '') - .replace(/<\/?thinking[^&]*>/gi, '') + return text.replace(/<\/?thinking[^>]*>/gi, '').replace(/<\/?thinking[^&]*>/gi, '') } function appendThinkingContent(context: StreamingContext, text: string) { @@ -1306,7 +1307,6 @@ const sseHandlers: Record = { upsertToolCallBlock(context, next) updateStreamingMessage(set, context) - // Do not execute on partial tool_call frames if (isPartial) { return @@ -1558,7 +1558,9 @@ const sseHandlers: Record = { hasProcessedContent = true } else { // Still in design_workflow block, accumulate content - const { text, remaining } = splitTrailingPartialTag(contentToProcess, ['']) + const { text, remaining } = splitTrailingPartialTag(contentToProcess, [ + '', + ]) context.designWorkflowContent += text // Update store with partial content for streaming effect (available in all modes) @@ -1670,14 +1672,14 @@ const sseHandlers: Record = { const endMatch = thinkingEndRegex.exec(contentToProcess) if (endMatch) { const thinkingContent = contentToProcess.substring(0, endMatch.index) - appendThinkingContent(context, thinkingContent) - finalizeThinkingBlock(context) + appendThinkingContent(context, thinkingContent) + finalizeThinkingBlock(context) contentToProcess = contentToProcess.substring(endMatch.index + endMatch[0].length) hasProcessedContent = true } else { const { text, remaining } = splitTrailingPartialTag(contentToProcess, ['']) if (text) { - appendThinkingContent(context, text) + appendThinkingContent(context, text) hasProcessedContent = true } contentToProcess = remaining @@ -1690,7 +1692,7 @@ const sseHandlers: Record = { if (startMatch) { const textBeforeThinking = contentToProcess.substring(0, startMatch.index) if (textBeforeThinking) { - appendTextBlock(context, textBeforeThinking) + appendTextBlock(context, textBeforeThinking) hasProcessedContent = true } context.isInThinkingBlock = true @@ -2772,8 +2774,10 @@ export const useCopilotStore = create()( .map((b: any) => b.content) .join('') || '' const nextContentBlocks = suppressContinueOption - ? lastMessage.contentBlocks ?? [] - : appendContinueOptionBlock(lastMessage.contentBlocks ? [...lastMessage.contentBlocks] : []) + ? (lastMessage.contentBlocks ?? []) + : appendContinueOptionBlock( + lastMessage.contentBlocks ? [...lastMessage.contentBlocks] : [] + ) set((state) => ({ messages: state.messages.map((msg) => msg.id === lastMessage.id @@ -3344,9 +3348,10 @@ export const useCopilotStore = create()( } const finalContent = stripTodoTags(context.accumulatedContent.toString()) - const finalContentWithOptions = context.wasAborted && !context.suppressContinueOption - ? appendContinueOption(finalContent) - : finalContent + const finalContentWithOptions = + context.wasAborted && !context.suppressContinueOption + ? appendContinueOption(finalContent) + : finalContent set((state) => { const snapshotId = state.currentUserMessageId const nextSnapshots = diff --git a/apps/sim/stores/panel/copilot/types.ts b/apps/sim/stores/panel/copilot/types.ts index d31ffc4b09..d0275a633e 100644 --- a/apps/sim/stores/panel/copilot/types.ts +++ b/apps/sim/stores/panel/copilot/types.ts @@ -1,5 +1,7 @@ import type { CopilotMode, CopilotModelId } from '@/lib/copilot/models' + export type { CopilotMode, CopilotModelId } from '@/lib/copilot/models' + import type { ClientToolCallState, ClientToolDisplay } from '@/lib/copilot/tools/client/base-tool' import type { WorkflowState } from '@/stores/workflows/workflow/types' diff --git a/apps/sim/stores/workflow-diff/store.ts b/apps/sim/stores/workflow-diff/store.ts index acb3b1bcc6..285be7e110 100644 --- a/apps/sim/stores/workflow-diff/store.ts +++ b/apps/sim/stores/workflow-diff/store.ts @@ -26,13 +26,15 @@ const diffEngine = new WorkflowDiffEngine() /** * Detects when a diff contains no meaningful changes. */ -function isEmptyDiffAnalysis(diffAnalysis?: { - new_blocks?: string[] - edited_blocks?: string[] - deleted_blocks?: string[] - field_diffs?: Record - edge_diff?: { new_edges?: string[]; deleted_edges?: string[] } -} | null): boolean { +function isEmptyDiffAnalysis( + diffAnalysis?: { + new_blocks?: string[] + edited_blocks?: string[] + deleted_blocks?: string[] + field_diffs?: Record + edge_diff?: { new_edges?: string[]; deleted_edges?: string[] } + } | null +): boolean { if (!diffAnalysis) return false const hasBlockChanges = (diffAnalysis.new_blocks?.length || 0) > 0 || @@ -41,10 +43,9 @@ function isEmptyDiffAnalysis(diffAnalysis?: { const hasEdgeChanges = (diffAnalysis.edge_diff?.new_edges?.length || 0) > 0 || (diffAnalysis.edge_diff?.deleted_edges?.length || 0) > 0 - const hasFieldChanges = - Object.values(diffAnalysis.field_diffs || {}).some( - (diff) => (diff?.changed_fields?.length || 0) > 0 - ) + const hasFieldChanges = Object.values(diffAnalysis.field_diffs || {}).some( + (diff) => (diff?.changed_fields?.length || 0) > 0 + ) return !hasBlockChanges && !hasEdgeChanges && !hasFieldChanges } From 561535055e00b4d32d5b36698dcc1d3a76918bad Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 16 Jan 2026 13:02:49 -0800 Subject: [PATCH 11/13] User input expansion --- .../components/copilot/components/user-input/user-input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b182634164..11a8574aef 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 @@ -816,7 +816,7 @@ const UserInput = forwardRef( placeholder={fileAttachments.isDragging ? 'Drop files here...' : effectivePlaceholder} disabled={disabled} rows={2} - className='relative z-[2] m-0 box-border h-auto min-h-[48px] w-full resize-none overflow-y-auto overflow-x-hidden break-words border-0 bg-transparent px-[2px] py-1 font-medium font-sans text-sm text-transparent leading-[1.25rem] caret-foreground outline-none [-ms-overflow-style:none] [scrollbar-width:none] [text-rendering:auto] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0 dark:placeholder:text-[var(--text-muted)] [&::-webkit-scrollbar]:hidden' + className='relative z-[2] m-0 box-border h-auto max-h-[120px] min-h-[48px] w-full resize-none overflow-y-auto overflow-x-hidden break-words border-0 bg-transparent px-[2px] py-1 font-medium font-sans text-sm text-transparent leading-[1.25rem] caret-foreground outline-none [-ms-overflow-style:none] [scrollbar-width:none] [text-rendering:auto] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0 dark:placeholder:text-[var(--text-muted)] [&::-webkit-scrollbar]:hidden' /> {/* Mention Menu Portal */} From 0e554555375f0d788d6d63e131f70039f19d2f6c Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 16 Jan 2026 13:22:40 -0800 Subject: [PATCH 12/13] Normalize copilot subblock ids --- .../tools/server/workflow/edit-workflow.ts | 74 +++++++++++++++---- 1 file changed, 58 insertions(+), 16 deletions(-) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts index 096e8b3381..794a4bb885 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts @@ -627,12 +627,9 @@ function createBlockFromParams( let sanitizedValue = value - // Special handling for inputFormat - ensure it's an array - if (key === 'inputFormat' && value !== null && value !== undefined) { - if (!Array.isArray(value)) { - // Invalid format, default to empty array - sanitizedValue = [] - } + // Normalize array subblocks with id fields (inputFormat, table rows, etc.) + if (shouldNormalizeArrayIds(key)) { + sanitizedValue = normalizeArrayWithIds(value) } // Special handling for tools - normalize and filter disallowed @@ -720,6 +717,55 @@ function normalizeTools(tools: any[]): any[] { }) } +/** UUID v4 regex pattern for validation */ +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +/** + * Subblock types that store arrays of objects with `id` fields. + * The LLM may generate arbitrary IDs which need to be converted to proper UUIDs. + */ +const ARRAY_WITH_ID_SUBBLOCK_TYPES = new Set([ + 'inputFormat', // input-format: Fields with id, name, type, value, collapsed + 'headers', // table: Rows with id, cells (used for HTTP headers) + 'params', // table: Rows with id, cells (used for query params) + 'variables', // table or variables-input: Rows/assignments with id + 'tagFilters', // knowledge-tag-filters: Filters with id, tagName, etc. + 'documentTags', // document-tag-entry: Tags with id, tagName, etc. + 'metrics', // eval-input: Metrics with id, name, description, range +]) + +/** + * Normalizes array subblock values by ensuring each item has a valid UUID. + * The LLM may generate arbitrary IDs like "input-desc-001" or "row-1" which need + * to be converted to proper UUIDs for consistency with UI-created items. + */ +function normalizeArrayWithIds(value: unknown): any[] { + if (!Array.isArray(value)) { + return [] + } + + return value.map((item: any) => { + if (!item || typeof item !== 'object') { + return item + } + + // Check if id is missing or not a valid UUID + const hasValidUUID = typeof item.id === 'string' && UUID_REGEX.test(item.id) + if (!hasValidUUID) { + return { ...item, id: crypto.randomUUID() } + } + + return item + }) +} + +/** + * Checks if a subblock key should have its array items normalized with UUIDs. + */ +function shouldNormalizeArrayIds(key: string): boolean { + return ARRAY_WITH_ID_SUBBLOCK_TYPES.has(key) +} + /** * Normalize responseFormat to ensure consistent storage * Handles both string (JSON) and object formats @@ -1360,12 +1406,9 @@ function applyOperationsToWorkflowState( } let sanitizedValue = value - // Special handling for inputFormat - ensure it's an array - if (key === 'inputFormat' && value !== null && value !== undefined) { - if (!Array.isArray(value)) { - // Invalid format, default to empty array - sanitizedValue = [] - } + // Normalize array subblocks with id fields (inputFormat, table rows, etc.) + if (shouldNormalizeArrayIds(key)) { + sanitizedValue = normalizeArrayWithIds(value) } // Special handling for tools - normalize and filter disallowed @@ -2011,10 +2054,9 @@ function applyOperationsToWorkflowState( let sanitizedValue = value - if (key === 'inputFormat' && value !== null && value !== undefined) { - if (!Array.isArray(value)) { - sanitizedValue = [] - } + // Normalize array subblocks with id fields (inputFormat, table rows, etc.) + if (shouldNormalizeArrayIds(key)) { + sanitizedValue = normalizeArrayWithIds(value) } // Special handling for tools - normalize and filter disallowed From ecd4237a0a424e9989182570177bedaf4838f957 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 16 Jan 2026 13:28:19 -0800 Subject: [PATCH 13/13] Fix handlecancelcheckpoint --- .../copilot-message/hooks/use-checkpoint-management.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks/use-checkpoint-management.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks/use-checkpoint-management.ts index 734cbd11a3..62606999c6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks/use-checkpoint-management.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks/use-checkpoint-management.ts @@ -193,7 +193,7 @@ export function useCheckpointManagement( onEditModeChange?.(false) onCancelEdit?.() pendingEditRef.current = null - }, []) + }, [onEditModeChange, onCancelEdit]) /** * Continues with edit WITHOUT reverting checkpoint