From debcd7601988b6c6a0b8392d0410b9554cf70b49 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 15 Jan 2026 10:01:00 -0800 Subject: [PATCH 01/10] improvement(slack): updated docs to include information for slack marketplace submission (#2837) --- apps/docs/content/docs/en/tools/slack.mdx | 21 +++++++++++++++++++++ apps/sim/lib/oauth/oauth.ts | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/docs/content/docs/en/tools/slack.mdx b/apps/docs/content/docs/en/tools/slack.mdx index a1e847cd63..4462adba61 100644 --- a/apps/docs/content/docs/en/tools/slack.mdx +++ b/apps/docs/content/docs/en/tools/slack.mdx @@ -43,6 +43,27 @@ In Sim, the Slack integration enables your agents to programmatically interact w - **Download files**: Retrieve files shared in Slack channels for processing or archival This allows for powerful automation scenarios such as sending notifications with dynamic updates, managing conversational flows with editable status messages, acknowledging important messages with reactions, and maintaining clean channels by removing outdated bot messages. Your agents can deliver timely information, update messages as workflows progress, create collaborative documents, or alert team members when attention is needed. This integration bridges the gap between your AI workflows and your team's communication, ensuring everyone stays informed with accurate, up-to-date information. By connecting Sim with Slack, you can create agents that keep your team updated with relevant information at the right time, enhance collaboration by sharing and updating insights automatically, and reduce the need for manual status updates—all while leveraging your existing Slack workspace where your team already communicates. + +## Getting Started + +To connect Slack to your Sim workflows: + +1. Sign up or log in at [sim.ai](https://sim.ai) +2. Create a new workflow or open an existing one +3. Drag a **Slack** block onto your canvas +4. Click the credential selector and choose **Connect** +5. Authorize Sim to access your Slack workspace +6. Select your target channel or user + +Once connected, you can use any of the Slack operations listed below. + +## AI-Generated Content + +Sim workflows may use AI models to generate messages and responses sent to Slack. AI-generated content may be inaccurate or contain errors. Always review automated outputs, especially for critical communications. + +## Need Help? + +If you encounter issues with the Slack integration, contact us at [help@sim.ai](mailto:help@sim.ai) {/* MANUAL-CONTENT-END */} diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 94496a24b0..d00fea2e4e 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -495,7 +495,7 @@ export const OAUTH_PROVIDERS: Record = { services: { slack: { name: 'Slack', - description: 'Send messages using a Slack bot.', + description: 'Send messages using a bot for Slack.', providerId: 'slack', icon: SlackIcon, baseProviderIcon: SlackIcon, From 5db5c1c7d68a1fec19b4ad6db90e0bb588eb4fc2 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 15 Jan 2026 10:55:40 -0800 Subject: [PATCH 02/10] 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 2bc181d3a60086ab001da7a32414774602bb437c Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 15 Jan 2026 11:48:29 -0800 Subject: [PATCH 03/10] 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 69309ecf5fe1a068a2fe7810ba4de9bc35fbf4bd Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 15 Jan 2026 11:58:50 -0800 Subject: [PATCH 04/10] 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 080ab9416572e96d8f84936c27e9c9318012ff68 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 15 Jan 2026 15:02:14 -0800 Subject: [PATCH 05/10] Cleanup --- .../diff-controls/diff-controls.tsx | 187 ++---------------- .../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, 215 insertions(+), 196 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 c34b7f73ed..04a54b668d 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 @@ -7,192 +7,33 @@ import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hoo 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') 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 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 handleAccept = useCallback(() => { logger.info('Accepting proposed changes with backup protection') @@ -229,12 +70,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 e63fd8c482e31afc84e06b6a0567bef447a25a6c Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 15 Jan 2026 15:06:01 -0800 Subject: [PATCH 06/10] 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 72384f190dba4d224f403f700d9b96e566ca69da Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 15 Jan 2026 15:10:42 -0800 Subject: [PATCH 07/10] 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 a10f32dfa505fb26abc33246892c3cd85907157c Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 15 Jan 2026 15:12:08 -0800 Subject: [PATCH 08/10] 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 59df90ab0cada9d579efbf4e21ec054c2507786d Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 15 Jan 2026 15:43:26 -0800 Subject: [PATCH 09/10] 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 23fdbbfea9a22341eed59ec9992ed8df350e98a3 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 15 Jan 2026 15:53:26 -0800 Subject: [PATCH 10/10] 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)