From b8ce6ede8365797d3212654346acd97543f30d2c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 12 Mar 2026 09:55:26 -0700 Subject: [PATCH 1/9] Connect play stop workflow in embedded view to workflow --- .../resource-content/resource-content.tsx | 18 +++++++++++++++--- .../utils/workflow-execution-utils.ts | 12 +++++++----- .../copilot/client-sse/run-tool-execution.ts | 19 +++++++++++++------ 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index f7317693b53..cb49acf0a62 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -18,7 +18,7 @@ import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/com import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' -import { useCurrentWorkflowExecution } from '@/stores/execution' +import { useExecutionStore } from '@/stores/execution/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const Workflow = lazy(() => import('@/app/workspace/[workspaceId]/w/[workflowId]/workflow')) @@ -90,7 +90,9 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor const { userPermissions: effectivePermissions } = useWorkspacePermissionsContext() const setActiveWorkflow = useWorkflowRegistry((state) => state.setActiveWorkflow) const { handleRunWorkflow, handleCancelExecution } = useWorkflowExecution() - const { isExecuting } = useCurrentWorkflowExecution() + const isExecuting = useExecutionStore( + (state) => state.workflowExecutions.get(workflowId)?.isExecuting ?? false + ) const { usageExceeded } = useUsageLimits() useEffect(() => { @@ -101,6 +103,8 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor !isExecuting && !effectivePermissions.canRead && !effectivePermissions.isLoading const handleRun = useCallback(async () => { + setActiveWorkflow(workflowId) + if (isExecuting) { await handleCancelExecution() return @@ -112,7 +116,15 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor } await handleRunWorkflow() - }, [handleCancelExecution, handleRunWorkflow, isExecuting, navigateToSettings, usageExceeded]) + }, [ + handleCancelExecution, + handleRunWorkflow, + isExecuting, + navigateToSettings, + setActiveWorkflow, + usageExceeded, + workflowId, + ]) const handleOpenWorkflow = useCallback(() => { router.push(`/workspace/${workspaceId}/w/${workflowId}`) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts index b43cca17c41..4eb87144de1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts @@ -86,6 +86,7 @@ export function markOutgoingEdgesFromOutput( } export interface WorkflowExecutionOptions { + workflowId?: string workflowInput?: any onStream?: (se: StreamingExecution) => Promise executionId?: string @@ -107,15 +108,16 @@ export async function executeWorkflowWithFullLogging( options: WorkflowExecutionOptions = {} ): Promise { const { activeWorkflowId } = useWorkflowRegistry.getState() + const targetWorkflowId = options.workflowId || activeWorkflowId - if (!activeWorkflowId) { + if (!targetWorkflowId) { throw new Error('No active workflow') } const executionId = options.executionId || uuidv4() const { addConsole } = useTerminalConsoleStore.getState() const { setActiveBlocks, setBlockRunStatus, setEdgeRunStatus } = useExecutionStore.getState() - const wfId = activeWorkflowId + const wfId = targetWorkflowId const workflowEdges = useWorkflowStore.getState().edges const activeBlocksSet = new Set() @@ -138,7 +140,7 @@ export async function executeWorkflowWithFullLogging( : {}), } - const response = await fetch(`/api/workflows/${activeWorkflowId}/execute`, { + const response = await fetch(`/api/workflows/${targetWorkflowId}/execute`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -220,7 +222,7 @@ export async function executeWorkflowWithFullLogging( startedAt: new Date(Date.now() - event.data.durationMs).toISOString(), executionOrder: event.data.executionOrder, endedAt: new Date().toISOString(), - workflowId: activeWorkflowId, + workflowId: targetWorkflowId, blockId: event.data.blockId, executionId, blockName: event.data.blockName, @@ -267,7 +269,7 @@ export async function executeWorkflowWithFullLogging( startedAt: new Date(Date.now() - event.data.durationMs).toISOString(), executionOrder: event.data.executionOrder, endedAt: new Date().toISOString(), - workflowId: activeWorkflowId, + workflowId: targetWorkflowId, blockId: event.data.blockId, executionId, blockName: event.data.blockName, diff --git a/apps/sim/lib/copilot/client-sse/run-tool-execution.ts b/apps/sim/lib/copilot/client-sse/run-tool-execution.ts index 82092d07537..1489c568016 100644 --- a/apps/sim/lib/copilot/client-sse/run-tool-execution.ts +++ b/apps/sim/lib/copilot/client-sse/run-tool-execution.ts @@ -39,17 +39,23 @@ async function doExecuteRunTool( toolName: string, params: Record ): Promise { - const { activeWorkflowId } = useWorkflowRegistry.getState() + const { activeWorkflowId, setActiveWorkflow } = useWorkflowRegistry.getState() + const targetWorkflowId = + typeof params.workflowId === 'string' && params.workflowId.length > 0 + ? params.workflowId + : activeWorkflowId - if (!activeWorkflowId) { + if (!targetWorkflowId) { logger.warn('[RunTool] Execution prevented: no active workflow', { toolCallId, toolName }) setToolState(toolCallId, ClientToolCallState.error) await reportCompletion(toolCallId, false, 'No active workflow found') return } + setActiveWorkflow(targetWorkflowId) + const { getWorkflowExecution, setIsExecuting } = useExecutionStore.getState() - const { isExecuting } = getWorkflowExecution(activeWorkflowId) + const { isExecuting } = getWorkflowExecution(targetWorkflowId) if (isExecuting) { logger.warn('[RunTool] Execution prevented: already executing', { toolCallId, toolName }) @@ -86,7 +92,7 @@ async function doExecuteRunTool( return undefined })() - setIsExecuting(activeWorkflowId, true) + setIsExecuting(targetWorkflowId, true) const executionId = uuidv4() const executionStartTime = new Date().toISOString() @@ -94,7 +100,7 @@ async function doExecuteRunTool( toolCallId, toolName, executionId, - activeWorkflowId, + workflowId: targetWorkflowId, hasInput: !!workflowInput, stopAfterBlockId, runFromBlock: runFromBlock ? { startBlockId: runFromBlock.startBlockId } : undefined, @@ -102,6 +108,7 @@ async function doExecuteRunTool( try { const result = await executeWorkflowWithFullLogging({ + workflowId: targetWorkflowId, workflowInput, executionId, overrideTriggerType: 'copilot', @@ -153,7 +160,7 @@ async function doExecuteRunTool( setToolState(toolCallId, ClientToolCallState.error) await reportCompletion(toolCallId, false, msg) } finally { - setIsExecuting(activeWorkflowId, false) + setIsExecuting(targetWorkflowId, false) } } From a89a7fa843c58bc3cdc0e2320ecde46b8ee1b133 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 12 Mar 2026 09:57:58 -0700 Subject: [PATCH 2/9] Fix stop not actually stoping workflow --- .../utils/workflow-execution-utils.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts index 4eb87144de1..cd30578f238 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts @@ -116,7 +116,8 @@ export async function executeWorkflowWithFullLogging( const executionId = options.executionId || uuidv4() const { addConsole } = useTerminalConsoleStore.getState() - const { setActiveBlocks, setBlockRunStatus, setEdgeRunStatus } = useExecutionStore.getState() + const { setActiveBlocks, setBlockRunStatus, setEdgeRunStatus, setCurrentExecutionId } = + useExecutionStore.getState() const wfId = targetWorkflowId const workflowEdges = useWorkflowStore.getState().edges @@ -185,6 +186,10 @@ export async function executeWorkflowWithFullLogging( const event = JSON.parse(data) switch (event.type) { + case 'execution:started': { + setCurrentExecutionId(wfId, event.executionId) + break + } case 'block:started': { updateActiveBlockRefCount( activeBlockRefCounts, @@ -304,6 +309,7 @@ export async function executeWorkflowWithFullLogging( } case 'execution:completed': + setCurrentExecutionId(wfId, null) executionResult = { success: event.data.success, output: event.data.output, @@ -316,7 +322,18 @@ export async function executeWorkflowWithFullLogging( } break + case 'execution:cancelled': + setCurrentExecutionId(wfId, null) + executionResult = { + success: false, + output: {}, + error: 'Execution was cancelled', + logs: [], + } + break + case 'execution:error': + setCurrentExecutionId(wfId, null) throw new Error(event.data.error || 'Execution failed') } } catch (parseError) { @@ -325,6 +342,7 @@ export async function executeWorkflowWithFullLogging( } } } finally { + setCurrentExecutionId(wfId, null) reader.releaseLock() setActiveBlocks(wfId, new Set()) } From 1dc91f8654e87fb5a382cc3ab484430d0d8dc9b5 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 12 Mar 2026 11:02:15 -0700 Subject: [PATCH 3/9] Fix ui not showing stopped by user --- apps/sim/app/api/copilot/confirm/route.ts | 2 +- .../components/agent-group/agent-group.tsx | 6 +- .../components/agent-group/tool-call-item.tsx | 26 +++++++- .../message-content/message-content.tsx | 4 +- .../resource-content/resource-content.tsx | 2 + .../[workspaceId]/home/hooks/use-chat.ts | 33 ++++++++-- .../app/workspace/[workspaceId]/home/types.ts | 2 +- .../copilot/client-sse/run-tool-execution.ts | 66 +++++++++++++++---- .../orchestrator/sse/handlers/handlers.ts | 48 ++++++++++++++ .../sse/handlers/tool-execution.ts | 3 +- apps/sim/lib/copilot/orchestrator/types.ts | 2 +- apps/sim/lib/copilot/types.ts | 1 + 12 files changed, 172 insertions(+), 23 deletions(-) diff --git a/apps/sim/app/api/copilot/confirm/route.ts b/apps/sim/app/api/copilot/confirm/route.ts index a6ef56ee494..a63333b356e 100644 --- a/apps/sim/app/api/copilot/confirm/route.ts +++ b/apps/sim/app/api/copilot/confirm/route.ts @@ -17,7 +17,7 @@ const logger = createLogger('CopilotConfirmAPI') // Schema for confirmation request const ConfirmationSchema = z.object({ toolCallId: z.string().min(1, 'Tool call ID is required'), - status: z.enum(['success', 'error', 'accepted', 'rejected', 'background'] as const, { + status: z.enum(['success', 'error', 'accepted', 'rejected', 'background', 'cancelled'] as const, { errorMap: () => ({ message: 'Invalid notification status' }), }), message: z.string().optional(), diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index 0fa61c0e8b6..fad81fa15a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -31,7 +31,11 @@ export function AgentGroup({ }: AgentGroupProps) { const AgentIcon = getAgentIcon(agentName) const hasTools = tools.length > 0 - const allDone = hasTools && tools.every((t) => t.status === 'success' || t.status === 'error') + const allDone = + hasTools && + tools.every( + (t) => t.status === 'success' || t.status === 'error' || t.status === 'cancelled' + ) const [expanded, setExpanded] = useState(!allDone) const [mounted, setMounted] = useState(!allDone) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx index cd8c3e31601..efa99186621 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx @@ -24,6 +24,22 @@ function CircleCheck({ className }: { className?: string }) { ) } +function CircleStop({ className }: { className?: string }) { + return ( + + + + + ) +} + interface ToolCallItemProps { toolName: string displayTitle: string @@ -38,13 +54,21 @@ export function ToolCallItem({ toolName, displayTitle, status }: ToolCallItemPro
{status === 'executing' ? ( + ) : status === 'cancelled' ? ( + ) : Icon ? ( ) : ( )}
- + {displayTitle} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 230829bb42b..1534bfee8e1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -212,7 +212,9 @@ export function MessageContent({ case 'agent_group': { const allToolsDone = segment.tools.length > 0 && - segment.tools.every((t) => t.status === 'success' || t.status === 'error') + segment.tools.every( + (t) => t.status === 'success' || t.status === 'error' || t.status === 'cancelled' + ) const hasFollowingText = segments.slice(i + 1).some((s) => s.type === 'text') return (
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index cb49acf0a62..733121c70fe 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation' import { Button, PlayOutline, Skeleton, Tooltip } from '@/components/emcn' import { BookOpen } from '@/components/emcn/icons' import { WorkflowIcon } from '@/components/icons' +import { reportManualRunToolStop } from '@/lib/copilot/client-sse/run-tool-execution' import { FileViewer, type PreviewMode, @@ -107,6 +108,7 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor if (isExecuting) { await handleCancelExecution() + await reportManualRunToolStop(workflowId) return } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 486f5338d0d..a27eb33b8f3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -57,6 +57,9 @@ export interface UseChatReturn { const STATE_TO_STATUS: Record = { success: 'success', error: 'error', + cancelled: 'cancelled', + rejected: 'error', + skipped: 'success', } as const function areResourcesEqual(left: MothershipResource[], right: MothershipResource[]): boolean { @@ -117,11 +120,13 @@ function mapStoredBlock(block: TaskStoredContentBlock): ContentBlock { } if (block.type === 'tool_call' && block.toolCall) { + const resolvedStatus = STATE_TO_STATUS[block.toolCall.state ?? ''] ?? 'error' mapped.toolCall = { id: block.toolCall.id ?? '', name: block.toolCall.name ?? 'unknown', - status: STATE_TO_STATUS[block.toolCall.state ?? ''] ?? 'success', - displayTitle: block.toolCall.display?.text, + status: resolvedStatus, + displayTitle: + resolvedStatus === 'cancelled' ? 'Stopped by user' : block.toolCall.display?.text, calledBy: block.toolCall.calledBy, result: block.toolCall.result, } @@ -131,12 +136,14 @@ function mapStoredBlock(block: TaskStoredContentBlock): ContentBlock { } function mapStoredToolCall(tc: TaskStoredToolCall): ContentBlock { + const resolvedStatus = (STATE_TO_STATUS[tc.status] ?? 'error') as ToolCallStatus return { type: 'tool_call', toolCall: { id: tc.id, name: tc.name, - status: (STATE_TO_STATUS[tc.status] ?? 'success') as ToolCallStatus, + status: resolvedStatus, + displayTitle: resolvedStatus === 'cancelled' ? 'Stopped by user' : undefined, result: tc.result != null ? { success: tc.status === 'success', output: tc.result, error: tc.error } @@ -577,7 +584,25 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet const idx = toolMap.get(id) if (idx !== undefined && blocks[idx].toolCall) { const tc = blocks[idx].toolCall! - tc.status = parsed.success ? 'success' : 'error' + + const payloadData = getPayloadData(parsed) + const resultObj = + parsed.result && typeof parsed.result === 'object' + ? (parsed.result as Record) + : undefined + const isCancelled = + resultObj?.reason === 'user_cancelled' || + resultObj?.cancelledByUser === true || + (payloadData as Record | undefined)?.reason === + 'user_cancelled' || + (payloadData as Record | undefined)?.cancelledByUser === true + + if (isCancelled) { + tc.status = 'cancelled' + tc.displayTitle = 'Stopped by user' + } else { + tc.status = parsed.success ? 'success' : 'error' + } tc.result = { success: !!parsed.success, output: parsed.result ?? getPayloadData(parsed)?.result, diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 2267c94311f..ad3a8c5363f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -96,7 +96,7 @@ export type ToolPhase = | 'resource' | 'subagent' -export type ToolCallStatus = 'executing' | 'success' | 'error' +export type ToolCallStatus = 'executing' | 'success' | 'error' | 'cancelled' export interface ToolCallInfo { id: string diff --git a/apps/sim/lib/copilot/client-sse/run-tool-execution.ts b/apps/sim/lib/copilot/client-sse/run-tool-execution.ts index 1489c568016..cfe0edcfb9c 100644 --- a/apps/sim/lib/copilot/client-sse/run-tool-execution.ts +++ b/apps/sim/lib/copilot/client-sse/run-tool-execution.ts @@ -9,6 +9,8 @@ import { useCopilotStore } from '@/stores/panel/copilot/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('CopilotRunToolExecution') +const activeRunToolByWorkflowId = new Map() +const manuallyStoppedToolCallIds = new Set() /** * Execute a run tool on the client side using the streaming execute endpoint. @@ -34,6 +36,28 @@ export function executeRunToolOnClient( }) } +/** + * Report a manual user-initiated stop for an active client-executed run tool. + * This lets Copilot know the run was intentionally cancelled by the user. + */ +export async function reportManualRunToolStop(workflowId: string): Promise { + const toolCallId = activeRunToolByWorkflowId.get(workflowId) + if (!toolCallId) return + + manuallyStoppedToolCallIds.add(toolCallId) + setToolState(toolCallId, ClientToolCallState.error) + await reportCompletion( + toolCallId, + 'cancelled', + 'Workflow execution was stopped manually by the user.', + { + reason: 'user_cancelled', + cancelledByUser: true, + workflowId, + } + ) +} + async function doExecuteRunTool( toolCallId: string, toolName: string, @@ -48,11 +72,12 @@ async function doExecuteRunTool( if (!targetWorkflowId) { logger.warn('[RunTool] Execution prevented: no active workflow', { toolCallId, toolName }) setToolState(toolCallId, ClientToolCallState.error) - await reportCompletion(toolCallId, false, 'No active workflow found') + await reportCompletion(toolCallId, 'error', 'No active workflow found') return } setActiveWorkflow(targetWorkflowId) + activeRunToolByWorkflowId.set(targetWorkflowId, toolCallId) const { getWorkflowExecution, setIsExecuting } = useExecutionStore.getState() const { isExecuting } = getWorkflowExecution(targetWorkflowId) @@ -60,7 +85,7 @@ async function doExecuteRunTool( if (isExecuting) { logger.warn('[RunTool] Execution prevented: already executing', { toolCallId, toolName }) setToolState(toolCallId, ClientToolCallState.error) - await reportCompletion(toolCallId, false, 'Workflow is already executing. Try again later') + await reportCompletion(toolCallId, 'error', 'Workflow is already executing. Try again later') return } @@ -139,12 +164,17 @@ async function doExecuteRunTool( } } catch {} - if (succeeded) { + if (manuallyStoppedToolCallIds.has(toolCallId)) { + logger.info('[RunTool] Skipping generic completion — already manually stopped', { + toolCallId, + toolName, + }) + } else if (succeeded) { logger.info('[RunTool] Workflow execution succeeded', { toolCallId, toolName }) setToolState(toolCallId, ClientToolCallState.success) await reportCompletion( toolCallId, - true, + 'success', `Workflow execution completed. Started at: ${executionStartTime}`, buildResultData(result) ) @@ -152,14 +182,26 @@ async function doExecuteRunTool( const msg = errorMessage || 'Workflow execution failed' logger.error('[RunTool] Workflow execution failed', { toolCallId, toolName, error: msg }) setToolState(toolCallId, ClientToolCallState.error) - await reportCompletion(toolCallId, false, msg, buildResultData(result)) + await reportCompletion(toolCallId, 'error', msg, buildResultData(result)) } } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - logger.error('[RunTool] Workflow execution threw', { toolCallId, toolName, error: msg }) - setToolState(toolCallId, ClientToolCallState.error) - await reportCompletion(toolCallId, false, msg) + if (manuallyStoppedToolCallIds.has(toolCallId)) { + logger.info('[RunTool] Skipping error completion — already manually stopped', { + toolCallId, + toolName, + }) + } else { + const msg = err instanceof Error ? err.message : String(err) + logger.error('[RunTool] Workflow execution threw', { toolCallId, toolName, error: msg }) + setToolState(toolCallId, ClientToolCallState.error) + await reportCompletion(toolCallId, 'error', msg) + } } finally { + manuallyStoppedToolCallIds.delete(toolCallId) + const activeToolCallId = activeRunToolByWorkflowId.get(targetWorkflowId) + if (activeToolCallId === toolCallId) { + activeRunToolByWorkflowId.delete(targetWorkflowId) + } setIsExecuting(targetWorkflowId, false) } } @@ -231,7 +273,7 @@ function buildResultData(result: unknown): Record | undefined { */ async function reportCompletion( toolCallId: string, - success: boolean, + status: 'success' | 'error' | 'cancelled', message?: string, data?: Record ): Promise { @@ -241,8 +283,8 @@ async function reportCompletion( headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ toolCallId, - status: success ? 'success' : 'error', - message: message || (success ? 'Tool completed' : 'Tool failed'), + status, + message: message || (status === 'success' ? 'Tool completed' : 'Tool failed'), ...(data ? { data } : {}), }), }) diff --git a/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts b/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts index 1a8cabade9e..ee4c17d7df8 100644 --- a/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts +++ b/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts @@ -91,6 +91,24 @@ function handleClientCompletion( markToolResultSeen(toolCallId) return } + if (completion?.status === 'cancelled') { + toolCall.status = 'cancelled' + toolCall.endTime = Date.now() + markToolComplete( + toolCall.id, + toolCall.name, + 499, + completion.message || 'Workflow execution was stopped manually by the user.', + completion.data + ).catch((err) => { + logger.error('markToolComplete fire-and-forget failed (client cancelled)', { + toolCallId: toolCall.id, + error: err instanceof Error ? err.message : String(err), + }) + }) + markToolResultSeen(toolCallId) + return + } const success = completion?.status === 'success' toolCall.status = success ? 'success' : 'error' toolCall.endTime = Date.now() @@ -107,6 +125,30 @@ function handleClientCompletion( markToolResultSeen(toolCallId) } +/** + * Emit a synthetic tool_result SSE event to the client after a client-executable + * tool completes. The Go backend's actual tool_result is skipped (markToolResultSeen), + * so the client would never learn the outcome without this. + */ +async function emitSyntheticToolResult( + toolCallId: string, + toolName: string, + completion: { status: string; message?: string; data?: Record } | null, + options: OrchestratorOptions +): Promise { + const success = completion?.status === 'success' + try { + await options.onEvent?.({ + type: 'tool_result', + toolCallId, + toolName, + success, + result: completion?.data, + error: !success ? completion?.message : undefined, + } as SSEEvent) + } catch {} +} + // Normalization + dedupe helpers live in sse-utils to keep server/client in sync. function inferToolSuccess(data: Record | undefined): { @@ -280,6 +322,7 @@ export const sseHandlers: Record = { options.abortSignal ) handleClientCompletion(toolCall, toolCallId, completion) + await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options) return } if (options.autoExecuteTools !== false) { @@ -304,6 +347,7 @@ export const sseHandlers: Record = { options.abortSignal ) handleClientCompletion(toolCall, toolCallId, completion) + await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options) return } fireToolExecution() @@ -372,6 +416,7 @@ export const sseHandlers: Record = { options.abortSignal ) handleClientCompletion(toolCall, toolCallId, completion) + await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options) return } @@ -537,6 +582,7 @@ export const subAgentHandlers: Record = { options.abortSignal ) handleClientCompletion(toolCall, toolCallId, completion) + await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options) return } if (options.autoExecuteTools !== false) { @@ -560,6 +606,7 @@ export const subAgentHandlers: Record = { options.abortSignal ) handleClientCompletion(toolCall, toolCallId, completion) + await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options) return } fireToolExecution() @@ -625,6 +672,7 @@ export const subAgentHandlers: Record = { options.abortSignal ) handleClientCompletion(toolCall, toolCallId, completion) + await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options) return } diff --git a/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts b/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts index 9ecef744c06..c4fef0c1bd9 100644 --- a/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts +++ b/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts @@ -596,7 +596,8 @@ export async function waitForToolCompletion( decision?.status === 'success' || decision?.status === 'error' || decision?.status === 'rejected' || - decision?.status === 'background' + decision?.status === 'background' || + decision?.status === 'cancelled' ) { return decision } diff --git a/apps/sim/lib/copilot/orchestrator/types.ts b/apps/sim/lib/copilot/orchestrator/types.ts index 6f5d57350fd..04163255d3c 100644 --- a/apps/sim/lib/copilot/orchestrator/types.ts +++ b/apps/sim/lib/copilot/orchestrator/types.ts @@ -40,7 +40,7 @@ export interface SSEEvent { ui?: Record } -export type ToolCallStatus = 'pending' | 'executing' | 'success' | 'error' | 'skipped' | 'rejected' +export type ToolCallStatus = 'pending' | 'executing' | 'success' | 'error' | 'skipped' | 'rejected' | 'cancelled' export interface ToolCallState { id: string diff --git a/apps/sim/lib/copilot/types.ts b/apps/sim/lib/copilot/types.ts index 79c617f01cb..031e835318a 100644 --- a/apps/sim/lib/copilot/types.ts +++ b/apps/sim/lib/copilot/types.ts @@ -7,6 +7,7 @@ export type NotificationStatus = | 'accepted' | 'rejected' | 'background' + | 'cancelled' export type { CopilotToolCall, ToolState } From 12e5e167d1f9545948d251947604aab9bebe5645 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 12 Mar 2026 11:05:04 -0700 Subject: [PATCH 4/9] Lint fix --- .../components/agent-group/agent-group.tsx | 4 +--- apps/sim/lib/copilot/orchestrator/types.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index fad81fa15a7..c7f53e1a83d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -33,9 +33,7 @@ export function AgentGroup({ const hasTools = tools.length > 0 const allDone = hasTools && - tools.every( - (t) => t.status === 'success' || t.status === 'error' || t.status === 'cancelled' - ) + tools.every((t) => t.status === 'success' || t.status === 'error' || t.status === 'cancelled') const [expanded, setExpanded] = useState(!allDone) const [mounted, setMounted] = useState(!allDone) diff --git a/apps/sim/lib/copilot/orchestrator/types.ts b/apps/sim/lib/copilot/orchestrator/types.ts index 04163255d3c..c4440c2be53 100644 --- a/apps/sim/lib/copilot/orchestrator/types.ts +++ b/apps/sim/lib/copilot/orchestrator/types.ts @@ -40,7 +40,14 @@ export interface SSEEvent { ui?: Record } -export type ToolCallStatus = 'pending' | 'executing' | 'success' | 'error' | 'skipped' | 'rejected' | 'cancelled' +export type ToolCallStatus = + | 'pending' + | 'executing' + | 'success' + | 'error' + | 'skipped' + | 'rejected' + | 'cancelled' export interface ToolCallState { id: string From 30d07f6183176212fd473c5382c8f3a8d6672ab8 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 12 Mar 2026 11:45:32 -0700 Subject: [PATCH 5/9] Plumb cancellation through system --- .../resource-content/resource-content.tsx | 12 ++-- .../hooks/use-workflow-execution.ts | 72 +++++++++++-------- .../copilot/client-sse/run-tool-execution.ts | 26 ++++++- .../orchestrator/sse/handlers/handlers.ts | 8 ++- .../tools/client/tool-display-registry.ts | 13 ++++ 5 files changed, 93 insertions(+), 38 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 733121c70fe..88b56639ff6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -6,7 +6,10 @@ import { useRouter } from 'next/navigation' import { Button, PlayOutline, Skeleton, Tooltip } from '@/components/emcn' import { BookOpen } from '@/components/emcn/icons' import { WorkflowIcon } from '@/components/icons' -import { reportManualRunToolStop } from '@/lib/copilot/client-sse/run-tool-execution' +import { + markRunToolManuallyStopped, + reportManualRunToolStop, +} from '@/lib/copilot/client-sse/run-tool-execution' import { FileViewer, type PreviewMode, @@ -90,7 +93,7 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor const { navigateToSettings } = useSettingsNavigation() const { userPermissions: effectivePermissions } = useWorkspacePermissionsContext() const setActiveWorkflow = useWorkflowRegistry((state) => state.setActiveWorkflow) - const { handleRunWorkflow, handleCancelExecution } = useWorkflowExecution() + const { handleRunWorkflow, handleCancelExecutionForWorkflow } = useWorkflowExecution() const isExecuting = useExecutionStore( (state) => state.workflowExecutions.get(workflowId)?.isExecuting ?? false ) @@ -107,7 +110,8 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor setActiveWorkflow(workflowId) if (isExecuting) { - await handleCancelExecution() + markRunToolManuallyStopped(workflowId) + handleCancelExecutionForWorkflow(workflowId) await reportManualRunToolStop(workflowId) return } @@ -119,7 +123,7 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor await handleRunWorkflow() }, [ - handleCancelExecution, + handleCancelExecutionForWorkflow, handleRunWorkflow, isExecuting, navigateToSettings, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index b8c7604fccb..dd09dc1483a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -1816,46 +1816,55 @@ export function useWorkflowExecution() { }, [resetDebugState]) /** - * Handles cancelling the current workflow execution + * Cancel a specific workflow by ID. Safe from stale-closure issues + * because it reads store state directly instead of relying on hook closures. */ - const handleCancelExecution = useCallback(() => { - if (!activeWorkflowId) return - logger.info('Workflow execution cancellation requested') + const handleCancelExecutionForWorkflow = useCallback( + (workflowId: string) => { + logger.info('Workflow execution cancellation requested', { workflowId }) - const storedExecutionId = getCurrentExecutionId(activeWorkflowId) + const storedExecutionId = getCurrentExecutionId(workflowId) - if (storedExecutionId) { - setCurrentExecutionId(activeWorkflowId, null) - fetch(`/api/workflows/${activeWorkflowId}/executions/${storedExecutionId}/cancel`, { - method: 'POST', - }).catch(() => {}) - handleExecutionCancelledConsole({ - workflowId: activeWorkflowId, - executionId: storedExecutionId, - }) - } + if (storedExecutionId) { + setCurrentExecutionId(workflowId, null) + fetch(`/api/workflows/${workflowId}/executions/${storedExecutionId}/cancel`, { + method: 'POST', + }).catch(() => {}) + handleExecutionCancelledConsole({ + workflowId, + executionId: storedExecutionId, + }) + } - executionStream.cancel(activeWorkflowId) + executionStream.cancel(workflowId) + setIsExecuting(workflowId, false) + setIsDebugging(workflowId, false) + setActiveBlocks(workflowId, new Set()) + }, + [ + executionStream, + setIsExecuting, + setIsDebugging, + setActiveBlocks, + getCurrentExecutionId, + setCurrentExecutionId, + handleExecutionCancelledConsole, + ] + ) + + /** + * Handles cancelling the current workflow execution (legacy wrapper). + */ + const handleCancelExecution = useCallback(() => { + if (!activeWorkflowId) return + + handleCancelExecutionForWorkflow(activeWorkflowId) currentChatExecutionIdRef.current = null - setIsExecuting(activeWorkflowId, false) - setIsDebugging(activeWorkflowId, false) - setActiveBlocks(activeWorkflowId, new Set()) if (isDebugging) { resetDebugState() } - }, [ - executionStream, - isDebugging, - resetDebugState, - setIsExecuting, - setIsDebugging, - setActiveBlocks, - activeWorkflowId, - getCurrentExecutionId, - setCurrentExecutionId, - handleExecutionCancelledConsole, - ]) + }, [activeWorkflowId, isDebugging, resetDebugState, handleCancelExecutionForWorkflow]) /** * Handles running workflow from a specific block using cached outputs @@ -2333,6 +2342,7 @@ export function useWorkflowExecution() { handleResumeDebug, handleCancelDebug, handleCancelExecution, + handleCancelExecutionForWorkflow, handleRunFromBlock, handleRunUntilBlock, } diff --git a/apps/sim/lib/copilot/client-sse/run-tool-execution.ts b/apps/sim/lib/copilot/client-sse/run-tool-execution.ts index cfe0edcfb9c..c62627075ad 100644 --- a/apps/sim/lib/copilot/client-sse/run-tool-execution.ts +++ b/apps/sim/lib/copilot/client-sse/run-tool-execution.ts @@ -36,16 +36,33 @@ export function executeRunToolOnClient( }) } +/** + * Synchronously mark the active run tool for a workflow as manually stopped. + * Must be called before issuing the cancellation request so that the + * concurrent doExecuteRunTool catch/success paths see the marker and skip + * their own completion report. + */ +export function markRunToolManuallyStopped(workflowId: string): void { + const toolCallId = activeRunToolByWorkflowId.get(workflowId) + if (!toolCallId) return + manuallyStoppedToolCallIds.add(toolCallId) + setToolState(toolCallId, ClientToolCallState.cancelled) +} + /** * Report a manual user-initiated stop for an active client-executed run tool. * This lets Copilot know the run was intentionally cancelled by the user. + * Call markRunToolManuallyStopped first to prevent race conditions. */ export async function reportManualRunToolStop(workflowId: string): Promise { const toolCallId = activeRunToolByWorkflowId.get(workflowId) if (!toolCallId) return - manuallyStoppedToolCallIds.add(toolCallId) - setToolState(toolCallId, ClientToolCallState.error) + if (!manuallyStoppedToolCallIds.has(toolCallId)) { + manuallyStoppedToolCallIds.add(toolCallId) + setToolState(toolCallId, ClientToolCallState.cancelled) + } + await reportCompletion( toolCallId, 'cancelled', @@ -117,8 +134,11 @@ async function doExecuteRunTool( return undefined })() + const { setCurrentExecutionId } = useExecutionStore.getState() + setIsExecuting(targetWorkflowId, true) const executionId = uuidv4() + setCurrentExecutionId(targetWorkflowId, executionId) const executionStartTime = new Date().toISOString() logger.info('[RunTool] Starting client-side workflow execution', { @@ -202,6 +222,8 @@ async function doExecuteRunTool( if (activeToolCallId === toolCallId) { activeRunToolByWorkflowId.delete(targetWorkflowId) } + const { setCurrentExecutionId: clearExecId } = useExecutionStore.getState() + clearExecId(targetWorkflowId, null) setIsExecuting(targetWorkflowId, false) } } diff --git a/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts b/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts index ee4c17d7df8..44a1ec85d38 100644 --- a/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts +++ b/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts @@ -137,13 +137,19 @@ async function emitSyntheticToolResult( options: OrchestratorOptions ): Promise { const success = completion?.status === 'success' + const isCancelled = completion?.status === 'cancelled' + + const resultPayload = isCancelled + ? { ...completion?.data, reason: 'user_cancelled', cancelledByUser: true } + : completion?.data + try { await options.onEvent?.({ type: 'tool_result', toolCallId, toolName, success, - result: completion?.data, + result: resultPayload, error: !success ? completion?.message : undefined, } as SSEEvent) } catch {} diff --git a/apps/sim/lib/copilot/tools/client/tool-display-registry.ts b/apps/sim/lib/copilot/tools/client/tool-display-registry.ts index d143e878d48..a387d3ad1c8 100644 --- a/apps/sim/lib/copilot/tools/client/tool-display-registry.ts +++ b/apps/sim/lib/copilot/tools/client/tool-display-registry.ts @@ -64,6 +64,7 @@ export enum ClientToolCallState { rejected = 'rejected', success = 'success', error = 'error', + cancelled = 'cancelled', review = 'review', background = 'background', } @@ -1432,6 +1433,7 @@ const META_run_block: ToolMetadata = { [ClientToolCallState.executing]: { text: 'Running block', icon: Loader2 }, [ClientToolCallState.success]: { text: 'Ran block', icon: Play }, [ClientToolCallState.error]: { text: 'Failed to run block', icon: XCircle }, + [ClientToolCallState.cancelled]: { text: 'Stopped by user', icon: MinusCircle }, [ClientToolCallState.rejected]: { text: 'Skipped running block', icon: MinusCircle }, [ClientToolCallState.aborted]: { text: 'Aborted running block', icon: MinusCircle }, [ClientToolCallState.background]: { text: 'Running block in background', icon: Play }, @@ -1473,6 +1475,8 @@ const META_run_block: ToolMetadata = { return `Run ${name}?` case ClientToolCallState.error: return `Failed to run ${name}` + case ClientToolCallState.cancelled: + return `Stopped running ${name}` case ClientToolCallState.rejected: return `Skipped running ${name}` case ClientToolCallState.aborted: @@ -1492,6 +1496,7 @@ const META_run_from_block: ToolMetadata = { [ClientToolCallState.executing]: { text: 'Running from block', icon: Loader2 }, [ClientToolCallState.success]: { text: 'Ran from block', icon: Play }, [ClientToolCallState.error]: { text: 'Failed to run from block', icon: XCircle }, + [ClientToolCallState.cancelled]: { text: 'Stopped by user', icon: MinusCircle }, [ClientToolCallState.rejected]: { text: 'Skipped running from block', icon: MinusCircle }, [ClientToolCallState.aborted]: { text: 'Aborted running from block', icon: MinusCircle }, [ClientToolCallState.background]: { text: 'Running from block in background', icon: Play }, @@ -1533,6 +1538,8 @@ const META_run_from_block: ToolMetadata = { return `Run from ${name}?` case ClientToolCallState.error: return `Failed to run from ${name}` + case ClientToolCallState.cancelled: + return `Stopped running from ${name}` case ClientToolCallState.rejected: return `Skipped running from ${name}` case ClientToolCallState.aborted: @@ -1552,6 +1559,7 @@ const META_run_workflow_until_block: ToolMetadata = { [ClientToolCallState.executing]: { text: 'Running until block', icon: Loader2 }, [ClientToolCallState.success]: { text: 'Ran until block', icon: Play }, [ClientToolCallState.error]: { text: 'Failed to run until block', icon: XCircle }, + [ClientToolCallState.cancelled]: { text: 'Stopped by user', icon: MinusCircle }, [ClientToolCallState.rejected]: { text: 'Skipped running until block', icon: MinusCircle }, [ClientToolCallState.aborted]: { text: 'Aborted running until block', icon: MinusCircle }, [ClientToolCallState.background]: { text: 'Running until block in background', icon: Play }, @@ -1593,6 +1601,8 @@ const META_run_workflow_until_block: ToolMetadata = { return `Run until ${name}?` case ClientToolCallState.error: return `Failed to run until ${name}` + case ClientToolCallState.cancelled: + return `Stopped running until ${name}` case ClientToolCallState.rejected: return `Skipped running until ${name}` case ClientToolCallState.aborted: @@ -1612,6 +1622,7 @@ const META_run_workflow: ToolMetadata = { [ClientToolCallState.executing]: { text: 'Running your workflow', icon: Loader2 }, [ClientToolCallState.success]: { text: 'Executed workflow', icon: Play }, [ClientToolCallState.error]: { text: 'Errored running workflow', icon: XCircle }, + [ClientToolCallState.cancelled]: { text: 'Stopped by user', icon: MinusCircle }, [ClientToolCallState.rejected]: { text: 'Skipped workflow execution', icon: MinusCircle }, [ClientToolCallState.aborted]: { text: 'Aborted workflow execution', icon: MinusCircle }, [ClientToolCallState.background]: { text: 'Running in background', icon: Play }, @@ -1679,6 +1690,8 @@ const META_run_workflow: ToolMetadata = { return `Run ${workflowName}?` case ClientToolCallState.error: return `Failed to run ${workflowName}` + case ClientToolCallState.cancelled: + return `Stopped ${workflowName}` case ClientToolCallState.rejected: return `Skipped running ${workflowName}` case ClientToolCallState.aborted: From c21a605fb6a7cf36a26cbdd0f71803004085d198 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 12 Mar 2026 12:01:37 -0700 Subject: [PATCH 6/9] Stopping mothership chat stops workflow --- .../[workspaceId]/home/hooks/use-chat.ts | 73 ++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index a27eb33b8f3..7999bf9efa5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -2,7 +2,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' import { usePathname } from 'next/navigation' -import { executeRunToolOnClient } from '@/lib/copilot/client-sse/run-tool-execution' +import { + executeRunToolOnClient, + markRunToolManuallyStopped, + reportManualRunToolStop, +} from '@/lib/copilot/client-sse/run-tool-execution' import { MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants' import { isWorkflowToolName } from '@/lib/copilot/workflow-tools' import { knowledgeKeys } from '@/hooks/queries/kb/knowledge' @@ -18,6 +22,9 @@ import { } from '@/hooks/queries/tasks' import { useWorkflows, workflowKeys } from '@/hooks/queries/workflows' import { useWorkspaceFiles, workspaceFilesKeys } from '@/hooks/queries/workspace-files' +import { useExecutionStream } from '@/hooks/use-execution-stream' +import { useExecutionStore } from '@/stores/execution/store' +import { useTerminalConsoleStore } from '@/stores/terminal' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { FileAttachmentForApi } from '../components/user-input/user-input' import type { @@ -225,6 +232,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet const pendingTableResourceIdsRef = useRef>(new Set()) const pendingWorkflowResourceIdsRef = useRef>(new Set()) + const executionStream = useExecutionStream() const isHomePage = pathname.endsWith('/home') const { data: chatHistory } = useChatHistory(initialChatId) @@ -949,7 +957,68 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet body: JSON.stringify({ streamId: sid }), }).catch(() => {}) } - }, [invalidateChatQueries, persistPartialResponse]) + + setMessages((prev) => + prev.map((msg) => { + if (!msg.contentBlocks?.some((b) => b.toolCall?.status === 'executing')) return msg + return { + ...msg, + contentBlocks: msg.contentBlocks!.map((block) => { + if (block.toolCall?.status !== 'executing') return block + return { + ...block, + toolCall: { + ...block.toolCall, + status: 'cancelled' as const, + displayTitle: 'Stopped by user', + }, + } + }), + } + }) + ) + + const execState = useExecutionStore.getState() + const consoleStore = useTerminalConsoleStore.getState() + for (const [workflowId, wfExec] of execState.workflowExecutions) { + if (!wfExec.isExecuting) continue + + markRunToolManuallyStopped(workflowId) + + const executionId = execState.getCurrentExecutionId(workflowId) + if (executionId) { + execState.setCurrentExecutionId(workflowId, null) + fetch(`/api/workflows/${workflowId}/executions/${executionId}/cancel`, { + method: 'POST', + }).catch(() => {}) + } + + consoleStore.cancelRunningEntries(workflowId) + const now = new Date() + consoleStore.addConsole({ + input: {}, + output: {}, + success: false, + error: 'Execution was cancelled', + durationMs: 0, + startedAt: now.toISOString(), + executionOrder: Number.MAX_SAFE_INTEGER, + endedAt: now.toISOString(), + workflowId, + blockId: 'cancelled', + executionId: executionId ?? undefined, + blockName: 'Execution Cancelled', + blockType: 'cancelled', + }) + + executionStream.cancel(workflowId) + execState.setIsExecuting(workflowId, false) + execState.setIsDebugging(workflowId, false) + execState.setActiveBlocks(workflowId, new Set()) + + reportManualRunToolStop(workflowId).catch(() => {}) + } + }, [invalidateChatQueries, persistPartialResponse, executionStream]) useEffect(() => { return () => { From 2d5351133067c88a6698a10f8e6247dd4f14f039 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 12 Mar 2026 12:28:12 -0700 Subject: [PATCH 7/9] Remove extra fluff --- .../resource-content/resource-content.tsx | 6 +- .../hooks/use-workflow-execution.ts | 72 ++++++++----------- 2 files changed, 34 insertions(+), 44 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 88b56639ff6..881cf23bef2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -93,7 +93,7 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor const { navigateToSettings } = useSettingsNavigation() const { userPermissions: effectivePermissions } = useWorkspacePermissionsContext() const setActiveWorkflow = useWorkflowRegistry((state) => state.setActiveWorkflow) - const { handleRunWorkflow, handleCancelExecutionForWorkflow } = useWorkflowExecution() + const { handleRunWorkflow, handleCancelExecution } = useWorkflowExecution() const isExecuting = useExecutionStore( (state) => state.workflowExecutions.get(workflowId)?.isExecuting ?? false ) @@ -111,7 +111,7 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor if (isExecuting) { markRunToolManuallyStopped(workflowId) - handleCancelExecutionForWorkflow(workflowId) + await handleCancelExecution() await reportManualRunToolStop(workflowId) return } @@ -123,7 +123,7 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor await handleRunWorkflow() }, [ - handleCancelExecutionForWorkflow, + handleCancelExecution, handleRunWorkflow, isExecuting, navigateToSettings, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index dd09dc1483a..b8c7604fccb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -1816,55 +1816,46 @@ export function useWorkflowExecution() { }, [resetDebugState]) /** - * Cancel a specific workflow by ID. Safe from stale-closure issues - * because it reads store state directly instead of relying on hook closures. - */ - const handleCancelExecutionForWorkflow = useCallback( - (workflowId: string) => { - logger.info('Workflow execution cancellation requested', { workflowId }) - - const storedExecutionId = getCurrentExecutionId(workflowId) - - if (storedExecutionId) { - setCurrentExecutionId(workflowId, null) - fetch(`/api/workflows/${workflowId}/executions/${storedExecutionId}/cancel`, { - method: 'POST', - }).catch(() => {}) - handleExecutionCancelledConsole({ - workflowId, - executionId: storedExecutionId, - }) - } - - executionStream.cancel(workflowId) - setIsExecuting(workflowId, false) - setIsDebugging(workflowId, false) - setActiveBlocks(workflowId, new Set()) - }, - [ - executionStream, - setIsExecuting, - setIsDebugging, - setActiveBlocks, - getCurrentExecutionId, - setCurrentExecutionId, - handleExecutionCancelledConsole, - ] - ) - - /** - * Handles cancelling the current workflow execution (legacy wrapper). + * Handles cancelling the current workflow execution */ const handleCancelExecution = useCallback(() => { if (!activeWorkflowId) return + logger.info('Workflow execution cancellation requested') + + const storedExecutionId = getCurrentExecutionId(activeWorkflowId) + + if (storedExecutionId) { + setCurrentExecutionId(activeWorkflowId, null) + fetch(`/api/workflows/${activeWorkflowId}/executions/${storedExecutionId}/cancel`, { + method: 'POST', + }).catch(() => {}) + handleExecutionCancelledConsole({ + workflowId: activeWorkflowId, + executionId: storedExecutionId, + }) + } - handleCancelExecutionForWorkflow(activeWorkflowId) + executionStream.cancel(activeWorkflowId) currentChatExecutionIdRef.current = null + setIsExecuting(activeWorkflowId, false) + setIsDebugging(activeWorkflowId, false) + setActiveBlocks(activeWorkflowId, new Set()) if (isDebugging) { resetDebugState() } - }, [activeWorkflowId, isDebugging, resetDebugState, handleCancelExecutionForWorkflow]) + }, [ + executionStream, + isDebugging, + resetDebugState, + setIsExecuting, + setIsDebugging, + setActiveBlocks, + activeWorkflowId, + getCurrentExecutionId, + setCurrentExecutionId, + handleExecutionCancelledConsole, + ]) /** * Handles running workflow from a specific block using cached outputs @@ -2342,7 +2333,6 @@ export function useWorkflowExecution() { handleResumeDebug, handleCancelDebug, handleCancelExecution, - handleCancelExecutionForWorkflow, handleRunFromBlock, handleRunUntilBlock, } From 626e584ae3db100707a27e003d618a3b525fd052 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 12 Mar 2026 12:32:44 -0700 Subject: [PATCH 8/9] Persist blocks on cancellation --- .../sim/app/api/mothership/chat/stop/route.ts | 41 +++++++++++++++++-- .../[workspaceId]/home/hooks/use-chat.ts | 38 ++++++++++++++++- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/api/mothership/chat/stop/route.ts b/apps/sim/app/api/mothership/chat/stop/route.ts index 0ff092b11e7..5c56634fc2e 100644 --- a/apps/sim/app/api/mothership/chat/stop/route.ts +++ b/apps/sim/app/api/mothership/chat/stop/route.ts @@ -8,10 +8,39 @@ import { getSession } from '@/lib/auth' const logger = createLogger('MothershipChatStopAPI') +const StoredToolCallSchema = z + .object({ + id: z.string().optional(), + name: z.string().optional(), + state: z.string().optional(), + params: z.record(z.unknown()).optional(), + result: z + .object({ + success: z.boolean(), + output: z.unknown().optional(), + error: z.string().optional(), + }) + .optional(), + display: z + .object({ + text: z.string().optional(), + }) + .optional(), + calledBy: z.string().optional(), + }) + .nullable() + +const ContentBlockSchema = z.object({ + type: z.string(), + content: z.string().optional(), + toolCall: StoredToolCallSchema.optional(), +}) + const StopSchema = z.object({ chatId: z.string(), streamId: z.string(), content: z.string(), + contentBlocks: z.array(ContentBlockSchema).optional(), }) /** @@ -26,20 +55,26 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { chatId, streamId, content } = StopSchema.parse(await req.json()) + const { chatId, streamId, content, contentBlocks } = StopSchema.parse(await req.json()) const setClause: Record = { conversationId: null, updatedAt: new Date(), } - if (content.trim()) { - const assistantMessage = { + const hasContent = content.trim().length > 0 + const hasBlocks = Array.isArray(contentBlocks) && contentBlocks.length > 0 + + if (hasContent || hasBlocks) { + const assistantMessage: Record = { id: crypto.randomUUID(), role: 'assistant' as const, content, timestamp: new Date().toISOString(), } + if (hasBlocks) { + assistantMessage.contentBlocks = contentBlocks + } setClause.messages = sql`${copilotChats.messages} || ${JSON.stringify([assistantMessage])}::jsonb` } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 7999bf9efa5..c3b32a3c8a9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -228,6 +228,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet const toolArgsMapRef = useRef>>(new Map()) const streamGenRef = useRef(0) const streamingContentRef = useRef('') + const streamingBlocksRef = useRef([]) const pendingFileResourceIdsRef = useRef>(new Set()) const pendingTableResourceIdsRef = useRef>(new Set()) const pendingWorkflowResourceIdsRef = useRef>(new Set()) @@ -445,6 +446,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet let lastContentSource: 'main' | 'subagent' | null = null streamingContentRef.current = '' + streamingBlocksRef.current = [] toolArgsMapRef.current.clear() const ensureTextBlock = (): ContentBlock => { @@ -456,6 +458,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet } const flush = () => { + streamingBlocksRef.current = [...blocks] setMessages((prev) => prev.map((m) => m.id === assistantId ? { ...m, content: runningText, contentBlocks: [...blocks] } : m @@ -776,13 +779,44 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet if (!chatId || !streamId) return const content = streamingContentRef.current + + const storedBlocks: TaskStoredContentBlock[] = streamingBlocksRef.current.map((block) => { + if (block.type === 'tool_call' && block.toolCall) { + const isCancelled = + block.toolCall.status === 'executing' || block.toolCall.status === 'cancelled' + return { + type: block.type, + content: block.content, + toolCall: { + id: block.toolCall.id, + name: block.toolCall.name, + state: isCancelled ? 'cancelled' : block.toolCall.status, + result: block.toolCall.result, + display: { + text: isCancelled ? 'Stopped by user' : block.toolCall.displayTitle, + }, + calledBy: block.toolCall.calledBy, + }, + } + } + return { type: block.type, content: block.content } + }) + try { const res = await fetch('/api/mothership/chat/stop', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ chatId, streamId, content }), + body: JSON.stringify({ + chatId, + streamId, + content, + ...(storedBlocks.length > 0 && { contentBlocks: storedBlocks }), + }), }) - if (res.ok) streamingContentRef.current = '' + if (res.ok) { + streamingContentRef.current = '' + streamingBlocksRef.current = [] + } } catch (err) { logger.warn('Failed to persist partial response', err) } From 880a89cc4c1bb27cecc6799ebb2d1aff42fa3288 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 12 Mar 2026 12:42:15 -0700 Subject: [PATCH 9/9] Add root level stopped by user --- .../components/agent-group/index.ts | 1 + .../components/agent-group/tool-call-item.tsx | 2 +- .../message-content/components/index.ts | 2 +- .../message-content/message-content.tsx | 26 ++++++++++++++-- .../[workspaceId]/home/hooks/use-chat.ts | 31 ++++++++++--------- .../app/workspace/[workspaceId]/home/types.ts | 8 ++++- 6 files changed, 51 insertions(+), 19 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/index.ts index 4368c7232e7..57eb4099b29 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/index.ts @@ -1 +1,2 @@ export { AgentGroup } from './agent-group' +export { CircleStop } from './tool-call-item' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx index efa99186621..50e11da727a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx @@ -24,7 +24,7 @@ function CircleCheck({ className }: { className?: string }) { ) } -function CircleStop({ className }: { className?: string }) { +export function CircleStop({ className }: { className?: string }) { return (
) + case 'stopped': + return ( +
+ + + Stopped by user + +
+ ) } })} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index c3b32a3c8a9..c699bf704d6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -802,6 +802,10 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet return { type: block.type, content: block.content } }) + if (storedBlocks.length > 0) { + storedBlocks.push({ type: 'stopped' }) + } + try { const res = await fetch('/api/mothership/chat/stop', { method: 'POST', @@ -995,20 +999,19 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet setMessages((prev) => prev.map((msg) => { if (!msg.contentBlocks?.some((b) => b.toolCall?.status === 'executing')) return msg - return { - ...msg, - contentBlocks: msg.contentBlocks!.map((block) => { - if (block.toolCall?.status !== 'executing') return block - return { - ...block, - toolCall: { - ...block.toolCall, - status: 'cancelled' as const, - displayTitle: 'Stopped by user', - }, - } - }), - } + const updated = msg.contentBlocks!.map((block) => { + if (block.toolCall?.status !== 'executing') return block + return { + ...block, + toolCall: { + ...block.toolCall, + status: 'cancelled' as const, + displayTitle: 'Stopped by user', + }, + } + }) + updated.push({ type: 'stopped' as const }) + return { ...msg, contentBlocks: updated } }) ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index ad3a8c5363f..60a7e14f270 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -113,7 +113,13 @@ export interface OptionItem { label: string } -export type ContentBlockType = 'text' | 'tool_call' | 'subagent' | 'subagent_text' | 'options' +export type ContentBlockType = + | 'text' + | 'tool_call' + | 'subagent' + | 'subagent_text' + | 'options' + | 'stopped' export interface ContentBlock { type: ContentBlockType