Compare commits

..

1 Commits

Author SHA1 Message Date
Waleed Latif
a4622c4943 fix(start-inp): fix start block uneditable on manual insertion 2026-01-16 13:08:20 -08:00
198 changed files with 3202 additions and 27870 deletions

View File

@@ -9,12 +9,12 @@
<p align="center">
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-6F3DFA" alt="Sim.ai"></a>
<a href="https://discord.gg/Hr4UWYEcTT" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Discord-Join%20Server-5865F2?logo=discord&logoColor=white" alt="Discord"></a>
<a href="https://x.com/simdotai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/twitter/follow/simdotai?style=social" alt="Twitter"></a>
<a href="https://x.com/simdotai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/twitter/follow/simstudioai?style=social" alt="Twitter"></a>
<a href="https://docs.sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Docs-6F3DFA.svg" alt="Documentation"></a>
</p>
<p align="center">
<a href="https://deepwiki.com/simstudioai/sim" target="_blank" rel="noopener noreferrer"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a> <a href="https://cursor.com/link/prompt?text=Help%20me%20set%20up%20Sim%20Studio%20locally.%20Follow%20these%20steps%3A%0A%0A1.%20First%2C%20verify%20Docker%20is%20installed%20and%20running%3A%0A%20%20%20docker%20--version%0A%20%20%20docker%20info%0A%0A2.%20Clone%20the%20repository%3A%0A%20%20%20git%20clone%20https%3A%2F%2Fgithub.com%2Fsimstudioai%2Fsim.git%0A%20%20%20cd%20sim%0A%0A3.%20Start%20the%20services%20with%20Docker%20Compose%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20up%20-d%0A%0A4.%20Wait%20for%20all%20containers%20to%20be%20healthy%20(this%20may%20take%201-2%20minutes)%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20ps%0A%0A5.%20Verify%20the%20app%20is%20accessible%20at%20http%3A%2F%2Flocalhost%3A3000%0A%0AIf%20there%20are%20any%20errors%2C%20help%20me%20troubleshoot%20them.%20Common%20issues%3A%0A-%20Port%203000%2C%203002%2C%20or%205432%20already%20in%20use%0A-%20Docker%20not%20running%0A-%20Insufficient%20memory%20(needs%2012GB%2B%20RAM)%0A%0AFor%20local%20AI%20models%20with%20Ollama%2C%20use%20this%20instead%20of%20step%203%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.ollama.yml%20--profile%20setup%20up%20-d"><img src="https://img.shields.io/badge/Set%20Up%20with-Cursor-000000?logo=cursor&logoColor=white" alt="Set Up with Cursor"></a>
<a href="https://cursor.com/link/prompt?text=Help%20me%20set%20up%20Sim%20Studio%20locally.%20Follow%20these%20steps%3A%0A%0A1.%20First%2C%20verify%20Docker%20is%20installed%20and%20running%3A%0A%20%20%20docker%20--version%0A%20%20%20docker%20info%0A%0A2.%20Clone%20the%20repository%3A%0A%20%20%20git%20clone%20https%3A%2F%2Fgithub.com%2Fsimstudioai%2Fsim.git%0A%20%20%20cd%20sim%0A%0A3.%20Start%20the%20services%20with%20Docker%20Compose%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20up%20-d%0A%0A4.%20Wait%20for%20all%20containers%20to%20be%20healthy%20(this%20may%20take%201-2%20minutes)%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20ps%0A%0A5.%20Verify%20the%20app%20is%20accessible%20at%20http%3A%2F%2Flocalhost%3A3000%0A%0AIf%20there%20are%20any%20errors%2C%20help%20me%20troubleshoot%20them.%20Common%20issues%3A%0A-%20Port%203000%2C%203002%2C%20or%205432%20already%20in%20use%0A-%20Docker%20not%20running%0A-%20Insufficient%20memory%20(needs%2012GB%2B%20RAM)%0A%0AFor%20local%20AI%20models%20with%20Ollama%2C%20use%20this%20instead%20of%20step%203%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.ollama.yml%20--profile%20setup%20up%20-d"><img src="https://img.shields.io/badge/Set%20Up%20with-Cursor-000000?logo=cursor&logoColor=white" alt="Set Up with Cursor"></a>
</p>
### Build Workflows with Ease

View File

@@ -1,3 +1,3 @@
{
"pages": ["index", "basics", "api", "logging", "costs"]
"pages": ["index", "basics", "api", "form", "logging", "costs"]
}

View File

@@ -36,47 +36,43 @@ Connect Google Vault to create exports, list exports, and manage holds within ma
### `google_vault_create_matters_export`
Create an export in a matter
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `matterId` | string | Yes | The matter ID |
| `exportName` | string | Yes | Name for the export \(avoid special characters\) |
| `corpus` | string | Yes | Data corpus to export \(MAIL, DRIVE, GROUPS, HANGOUTS_CHAT, VOICE\) |
| `accountEmails` | string | No | Comma-separated list of user emails to scope export |
| `orgUnitId` | string | No | Organization unit ID to scope export \(alternative to emails\) |
| `startTime` | string | No | Start time for date filtering \(ISO 8601 format, e.g., 2024-01-01T00:00:00Z\) |
| `endTime` | string | No | End time for date filtering \(ISO 8601 format, e.g., 2024-12-31T23:59:59Z\) |
| `terms` | string | No | Search query terms to filter exported content |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `export` | json | Created export object |
| `matters` | json | Array of matter objects \(for list_matters\) |
| `exports` | json | Array of export objects \(for list_matters_export\) |
| `holds` | json | Array of hold objects \(for list_matters_holds\) |
| `matter` | json | Created matter object \(for create_matters\) |
| `export` | json | Created export object \(for create_matters_export\) |
| `hold` | json | Created hold object \(for create_matters_holds\) |
| `file` | json | Downloaded export file \(UserFile\) from execution files |
| `nextPageToken` | string | Token for fetching next page of results \(for list operations\) |
### `google_vault_list_matters_export`
List exports for a matter
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `matterId` | string | Yes | The matter ID |
| `pageSize` | number | No | Number of exports to return per page |
| `pageToken` | string | No | Token for pagination |
| `exportId` | string | No | Optional export ID to fetch a specific export |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `exports` | json | Array of export objects |
| `export` | json | Single export object \(when exportId is provided\) |
| `nextPageToken` | string | Token for fetching next page of results |
| `matters` | json | Array of matter objects \(for list_matters\) |
| `exports` | json | Array of export objects \(for list_matters_export\) |
| `holds` | json | Array of hold objects \(for list_matters_holds\) |
| `matter` | json | Created matter object \(for create_matters\) |
| `export` | json | Created export object \(for create_matters_export\) |
| `hold` | json | Created hold object \(for create_matters_holds\) |
| `file` | json | Downloaded export file \(UserFile\) from execution files |
| `nextPageToken` | string | Token for fetching next page of results \(for list operations\) |
### `google_vault_download_export_file`
@@ -86,10 +82,10 @@ Download a single file from a Google Vault export (GCS object)
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `matterId` | string | Yes | The matter ID |
| `bucketName` | string | Yes | GCS bucket name from cloudStorageSink.files.bucketName |
| `objectName` | string | Yes | GCS object name from cloudStorageSink.files.objectName |
| `fileName` | string | No | Optional filename override for the downloaded file |
| `matterId` | string | Yes | No description |
| `bucketName` | string | Yes | No description |
| `objectName` | string | Yes | No description |
| `fileName` | string | No | No description |
#### Output
@@ -99,84 +95,82 @@ Download a single file from a Google Vault export (GCS object)
### `google_vault_create_matters_holds`
Create a hold in a matter
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `matterId` | string | Yes | The matter ID |
| `holdName` | string | Yes | Name for the hold |
| `corpus` | string | Yes | Data corpus to hold \(MAIL, DRIVE, GROUPS, HANGOUTS_CHAT, VOICE\) |
| `accountEmails` | string | No | Comma-separated list of user emails to put on hold |
| `orgUnitId` | string | No | Organization unit ID to put on hold \(alternative to accounts\) |
| `terms` | string | No | Search terms to filter held content \(for MAIL and GROUPS corpus\) |
| `startTime` | string | No | Start time for date filtering \(ISO 8601 format, for MAIL and GROUPS corpus\) |
| `endTime` | string | No | End time for date filtering \(ISO 8601 format, for MAIL and GROUPS corpus\) |
| `includeSharedDrives` | boolean | No | Include files in shared drives \(for DRIVE corpus\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `hold` | json | Created hold object |
| `matters` | json | Array of matter objects \(for list_matters\) |
| `exports` | json | Array of export objects \(for list_matters_export\) |
| `holds` | json | Array of hold objects \(for list_matters_holds\) |
| `matter` | json | Created matter object \(for create_matters\) |
| `export` | json | Created export object \(for create_matters_export\) |
| `hold` | json | Created hold object \(for create_matters_holds\) |
| `file` | json | Downloaded export file \(UserFile\) from execution files |
| `nextPageToken` | string | Token for fetching next page of results \(for list operations\) |
### `google_vault_list_matters_holds`
List holds for a matter
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `matterId` | string | Yes | The matter ID |
| `pageSize` | number | No | Number of holds to return per page |
| `pageToken` | string | No | Token for pagination |
| `holdId` | string | No | Optional hold ID to fetch a specific hold |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `holds` | json | Array of hold objects |
| `hold` | json | Single hold object \(when holdId is provided\) |
| `nextPageToken` | string | Token for fetching next page of results |
| `matters` | json | Array of matter objects \(for list_matters\) |
| `exports` | json | Array of export objects \(for list_matters_export\) |
| `holds` | json | Array of hold objects \(for list_matters_holds\) |
| `matter` | json | Created matter object \(for create_matters\) |
| `export` | json | Created export object \(for create_matters_export\) |
| `hold` | json | Created hold object \(for create_matters_holds\) |
| `file` | json | Downloaded export file \(UserFile\) from execution files |
| `nextPageToken` | string | Token for fetching next page of results \(for list operations\) |
### `google_vault_create_matters`
Create a new matter in Google Vault
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `name` | string | Yes | Name for the new matter |
| `description` | string | No | Optional description for the matter |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `matter` | json | Created matter object |
| `matters` | json | Array of matter objects \(for list_matters\) |
| `exports` | json | Array of export objects \(for list_matters_export\) |
| `holds` | json | Array of hold objects \(for list_matters_holds\) |
| `matter` | json | Created matter object \(for create_matters\) |
| `export` | json | Created export object \(for create_matters_export\) |
| `hold` | json | Created hold object \(for create_matters_holds\) |
| `file` | json | Downloaded export file \(UserFile\) from execution files |
| `nextPageToken` | string | Token for fetching next page of results \(for list operations\) |
### `google_vault_list_matters`
List matters, or get a specific matter if matterId is provided
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `pageSize` | number | No | Number of matters to return per page |
| `pageToken` | string | No | Token for pagination |
| `matterId` | string | No | Optional matter ID to fetch a specific matter |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `matters` | json | Array of matter objects |
| `matter` | json | Single matter object \(when matterId is provided\) |
| `nextPageToken` | string | Token for fetching next page of results |
| `matters` | json | Array of matter objects \(for list_matters\) |
| `exports` | json | Array of export objects \(for list_matters_export\) |
| `holds` | json | Array of hold objects \(for list_matters_holds\) |
| `matter` | json | Created matter object \(for create_matters\) |
| `export` | json | Created export object \(for create_matters_export\) |
| `hold` | json | Created hold object \(for create_matters_holds\) |
| `file` | json | Downloaded export file \(UserFile\) from execution files |
| `nextPageToken` | string | Token for fetching next page of results \(for list operations\) |

View File

@@ -11,8 +11,10 @@
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"content/docs/execution/index.mdx",
"content/docs/connections/index.mdx"
"content/docs/connections/index.mdx",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules", ".next"]
"exclude": ["node_modules"]
}

View File

@@ -8,7 +8,6 @@ import { getSession } from '@/lib/auth'
import { generateChatTitle } from '@/lib/copilot/chat-title'
import { getCopilotModel } from '@/lib/copilot/config'
import { SIM_AGENT_API_URL_DEFAULT, SIM_AGENT_VERSION } from '@/lib/copilot/constants'
import { COPILOT_MODEL_IDS, COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
@@ -41,8 +40,34 @@ 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(COPILOT_MODEL_IDS).optional().default('claude-4.5-opus'),
mode: z.enum(COPILOT_REQUEST_MODES).optional().default('agent'),
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'),
prefetch: z.boolean().optional(),
createNewChat: z.boolean().optional().default(false),
stream: z.boolean().optional().default(true),
@@ -270,8 +295,7 @@ export async function POST(req: NextRequest) {
}
const defaults = getCopilotModel('chat')
const selectedModel = model || defaults.model
const envModel = env.COPILOT_MODEL || defaults.model
const modelToUse = env.COPILOT_MODEL || defaults.model
let providerConfig: CopilotProviderConfig | undefined
const providerEnv = env.COPILOT_PROVIDER as any
@@ -280,7 +304,7 @@ export async function POST(req: NextRequest) {
if (providerEnv === 'azure-openai') {
providerConfig = {
provider: 'azure-openai',
model: envModel,
model: modelToUse,
apiKey: env.AZURE_OPENAI_API_KEY,
apiVersion: 'preview',
endpoint: env.AZURE_OPENAI_ENDPOINT,
@@ -288,7 +312,7 @@ export async function POST(req: NextRequest) {
} else if (providerEnv === 'vertex') {
providerConfig = {
provider: 'vertex',
model: envModel,
model: modelToUse,
apiKey: env.COPILOT_API_KEY,
vertexProject: env.VERTEX_PROJECT,
vertexLocation: env.VERTEX_LOCATION,
@@ -296,15 +320,12 @@ export async function POST(req: NextRequest) {
} else {
providerConfig = {
provider: providerEnv,
model: selectedModel,
model: modelToUse,
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
@@ -324,7 +345,7 @@ export async function POST(req: NextRequest) {
}
} | null = null
if (effectiveMode === 'build') {
if (mode === 'agent') {
// Build base tools (executed locally, not deferred)
// Include function_execute for code execution capability
baseTools = [
@@ -431,8 +452,8 @@ export async function POST(req: NextRequest) {
userId: authenticatedUserId,
stream: stream,
streamToolCalls: true,
model: selectedModel,
mode: transportMode,
model: model,
mode: mode,
messageId: userMessageIdToUse,
version: SIM_AGENT_VERSION,
...(providerConfig ? { provider: providerConfig } : {}),
@@ -456,7 +477,7 @@ export async function POST(req: NextRequest) {
hasConversationId: !!effectiveConversationId,
hasFileAttachments: processedFileContents.length > 0,
messageLength: message.length,
mode: effectiveMode,
mode,
hasTools: integrationTools.length > 0,
toolCount: integrationTools.length,
hasBaseTools: baseTools.length > 0,

View File

@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { COPILOT_MODES } from '@/lib/copilot/models'
import {
authenticateCopilotRequestSessionOnly,
createInternalServerErrorResponse,
@@ -46,7 +45,7 @@ const UpdateMessagesSchema = z.object({
planArtifact: z.string().nullable().optional(),
config: z
.object({
mode: z.enum(COPILOT_MODES).optional(),
mode: z.enum(['ask', 'build', 'plan']).optional(),
model: z.string().optional(),
})
.nullable()

View File

@@ -14,7 +14,8 @@ import {
import { generateRequestId } from '@/lib/core/utils/request'
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
import { REFERENCE } from '@/executor/constants'
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
import { executeTool } from '@/tools'
import { getTool, resolveToolId } from '@/tools/utils'
@@ -27,6 +28,45 @@ const ExecuteToolSchema = z.object({
workflowId: z.string().optional(),
})
/**
* Resolves all {{ENV_VAR}} references in a value recursively
* Works with strings, arrays, and objects
*/
function resolveEnvVarReferences(value: any, envVars: Record<string, string>): any {
if (typeof value === 'string') {
// Check for exact match: entire string is "{{VAR_NAME}}"
const exactMatchPattern = new RegExp(
`^\\${REFERENCE.ENV_VAR_START}([^}]+)\\${REFERENCE.ENV_VAR_END}$`
)
const exactMatch = exactMatchPattern.exec(value)
if (exactMatch) {
const envVarName = exactMatch[1].trim()
return envVars[envVarName] ?? value
}
// Check for embedded references: "prefix {{VAR}} suffix"
const envVarPattern = createEnvVarPattern()
return value.replace(envVarPattern, (match, varName) => {
const trimmedName = varName.trim()
return envVars[trimmedName] ?? match
})
}
if (Array.isArray(value)) {
return value.map((item) => resolveEnvVarReferences(item, envVars))
}
if (value !== null && typeof value === 'object') {
const resolved: Record<string, any> = {}
for (const [key, val] of Object.entries(value)) {
resolved[key] = resolveEnvVarReferences(val, envVars)
}
return resolved
}
return value
}
export async function POST(req: NextRequest) {
const tracker = createRequestTracker()
@@ -105,17 +145,7 @@ export async function POST(req: NextRequest) {
// Build execution params starting with LLM-provided arguments
// Resolve all {{ENV_VAR}} references in the arguments
const executionParams: Record<string, any> = resolveEnvVarReferences(
toolArgs,
decryptedEnvVars,
{
resolveExactMatch: true,
allowEmbedded: true,
trimKeys: true,
onMissing: 'keep',
deep: true,
}
) as Record<string, any>
const executionParams: Record<string, any> = resolveEnvVarReferences(toolArgs, decryptedEnvVars)
logger.info(`[${tracker.requestId}] Resolved env var references in arguments`, {
toolName,

View File

@@ -2,13 +2,12 @@ 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<CopilotModelId, boolean> = {
const DEFAULT_ENABLED_MODELS: Record<string, boolean> = {
'gpt-4o': false,
'gpt-4.1': false,
'gpt-5-fast': false,
@@ -29,7 +28,7 @@ const DEFAULT_ENABLED_MODELS: Record<CopilotModelId, boolean> = {
'claude-4.5-haiku': true,
'claude-4.5-sonnet': true,
'claude-4.5-opus': true,
'claude-4.1-opus': false,
// 'claude-4.1-opus': true,
'gemini-3-pro': true,
}
@@ -55,9 +54,7 @@ export async function GET(request: NextRequest) {
const mergedModels = { ...DEFAULT_ENABLED_MODELS }
for (const [modelId, enabled] of Object.entries(userModelsMap)) {
if (modelId in mergedModels) {
mergedModels[modelId as CopilotModelId] = enabled
}
mergedModels[modelId] = enabled
}
const hasNewModels = Object.keys(DEFAULT_ENABLED_MODELS).some(

View File

@@ -11,7 +11,6 @@ import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import { setFormAuthCookie, validateFormAuth } from '@/app/api/form/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -36,7 +35,10 @@ async function getWorkflowInputSchema(workflowId: string): Promise<any[]> {
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, workflowId))
const startBlock = blocks.find((block) => isValidStartBlockType(block.type))
const startBlock = blocks.find(
(block) =>
block.type === 'starter' || block.type === 'start_trigger' || block.type === 'input_trigger'
)
if (!startBlock) {
return []

View File

@@ -9,7 +9,6 @@ import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants'
import {
createEnvVarPattern,
createWorkflowVariablePattern,
resolveEnvVarReferences,
} from '@/executor/utils/reference-validation'
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
@@ -480,29 +479,9 @@ function resolveEnvironmentVariables(
const replacements: Array<{ match: string; index: number; varName: string; varValue: string }> =
[]
const resolverVars: Record<string, string> = {}
Object.entries(params).forEach(([key, value]) => {
if (value) {
resolverVars[key] = String(value)
}
})
Object.entries(envVars).forEach(([key, value]) => {
if (value) {
resolverVars[key] = value
}
})
while ((match = regex.exec(code)) !== null) {
const varName = match[1].trim()
const resolved = resolveEnvVarReferences(match[0], resolverVars, {
allowEmbedded: true,
resolveExactMatch: true,
trimKeys: true,
onMissing: 'empty',
deep: false,
})
const varValue =
typeof resolved === 'string' ? resolved : resolved == null ? '' : String(resolved)
const varValue = envVars[varName] || params[varName] || ''
replacements.push({
match: match[0],
index: match.index,

View File

@@ -20,7 +20,6 @@ import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateInternalToken } from '@/lib/auth/internal'
import { getBaseUrl } from '@/lib/core/utils/urls'
const logger = createLogger('WorkflowMcpServeAPI')
@@ -53,8 +52,6 @@ async function getServer(serverId: string) {
id: workflowMcpServer.id,
name: workflowMcpServer.name,
workspaceId: workflowMcpServer.workspaceId,
isPublic: workflowMcpServer.isPublic,
createdBy: workflowMcpServer.createdBy,
})
.from(workflowMcpServer)
.where(eq(workflowMcpServer.id, serverId))
@@ -93,11 +90,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
}
if (!server.isPublic) {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
@@ -143,8 +138,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
id,
serverId,
rpcParams as { name: string; arguments?: Record<string, unknown> },
apiKey,
server.isPublic ? server.createdBy : undefined
apiKey
)
default:
@@ -206,8 +200,7 @@ async function handleToolsCall(
id: RequestId,
serverId: string,
params: { name: string; arguments?: Record<string, unknown> } | undefined,
apiKey?: string | null,
publicServerOwnerId?: string
apiKey?: string | null
): Promise<NextResponse> {
try {
if (!params?.name) {
@@ -250,13 +243,7 @@ async function handleToolsCall(
const executeUrl = `${getBaseUrl()}/api/workflows/${tool.workflowId}/execute`
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (publicServerOwnerId) {
const internalToken = await generateInternalToken(publicServerOwnerId)
headers.Authorization = `Bearer ${internalToken}`
} else if (apiKey) {
headers['X-API-Key'] = apiKey
}
if (apiKey) headers['X-API-Key'] = apiKey
logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${params.name}`)

View File

@@ -5,7 +5,8 @@ import { McpClient } from '@/lib/mcp/client'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import type { McpServerConfig, McpTransport } from '@/lib/mcp/types'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
import { REFERENCE } from '@/executor/constants'
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
const logger = createLogger('McpServerTestAPI')
@@ -23,23 +24,22 @@ function isUrlBasedTransport(transport: McpTransport): boolean {
* Resolve environment variables in strings
*/
function resolveEnvVars(value: string, envVars: Record<string, string>): string {
const missingVars: string[] = []
const resolvedValue = resolveEnvVarReferences(value, envVars, {
allowEmbedded: true,
resolveExactMatch: true,
trimKeys: true,
onMissing: 'keep',
deep: false,
missingKeys: missingVars,
}) as string
const envVarPattern = createEnvVarPattern()
const envMatches = value.match(envVarPattern)
if (!envMatches) return value
if (missingVars.length > 0) {
const uniqueMissing = Array.from(new Set(missingVars))
uniqueMissing.forEach((envKey) => {
let resolvedValue = value
for (const match of envMatches) {
const envKey = match.slice(REFERENCE.ENV_VAR_START.length, -REFERENCE.ENV_VAR_END.length).trim()
const envValue = envVars[envKey]
if (envValue === undefined) {
logger.warn(`Environment variable "${envKey}" not found in MCP server test`)
})
}
continue
}
resolvedValue = resolvedValue.replace(match, envValue)
}
return resolvedValue
}

View File

@@ -31,7 +31,6 @@ export const GET = withMcpAuth<RouteParams>('read')(
createdBy: workflowMcpServer.createdBy,
name: workflowMcpServer.name,
description: workflowMcpServer.description,
isPublic: workflowMcpServer.isPublic,
createdAt: workflowMcpServer.createdAt,
updatedAt: workflowMcpServer.updatedAt,
})
@@ -99,9 +98,6 @@ export const PATCH = withMcpAuth<RouteParams>('write')(
if (body.description !== undefined) {
updateData.description = body.description?.trim() || null
}
if (body.isPublic !== undefined) {
updateData.isPublic = body.isPublic
}
const [updatedServer] = await db
.update(workflowMcpServer)

View File

@@ -26,6 +26,7 @@ export const GET = withMcpAuth<RouteParams>('read')(
logger.info(`[${requestId}] Getting tool ${toolId} from server ${serverId}`)
// Verify server exists and belongs to workspace
const [server] = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)
@@ -71,6 +72,7 @@ export const PATCH = withMcpAuth<RouteParams>('write')(
logger.info(`[${requestId}] Updating tool ${toolId} in server ${serverId}`)
// Verify server exists and belongs to workspace
const [server] = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)
@@ -137,6 +139,7 @@ export const DELETE = withMcpAuth<RouteParams>('write')(
logger.info(`[${requestId}] Deleting tool ${toolId} from server ${serverId}`)
// Verify server exists and belongs to workspace
const [server] = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)

View File

@@ -6,10 +6,24 @@ import type { NextRequest } from 'next/server'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
const logger = createLogger('WorkflowMcpToolsAPI')
/**
* Check if a workflow has a valid start block by loading from database
*/
async function hasValidStartBlock(workflowId: string): Promise<boolean> {
try {
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
return hasValidStartBlockInState(normalizedData)
} catch (error) {
logger.warn('Error checking for start block:', error)
return false
}
}
export const dynamic = 'force-dynamic'
interface RouteParams {
@@ -26,6 +40,7 @@ export const GET = withMcpAuth<RouteParams>('read')(
logger.info(`[${requestId}] Listing tools for workflow MCP server: ${serverId}`)
// Verify server exists and belongs to workspace
const [server] = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)
@@ -38,6 +53,7 @@ export const GET = withMcpAuth<RouteParams>('read')(
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
}
// Get tools with workflow details
const tools = await db
.select({
id: workflowMcpTool.id,
@@ -91,6 +107,7 @@ export const POST = withMcpAuth<RouteParams>('write')(
)
}
// Verify server exists and belongs to workspace
const [server] = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)
@@ -103,6 +120,7 @@ export const POST = withMcpAuth<RouteParams>('write')(
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
}
// Verify workflow exists and is deployed
const [workflowRecord] = await db
.select({
id: workflow.id,
@@ -119,6 +137,7 @@ export const POST = withMcpAuth<RouteParams>('write')(
return createMcpErrorResponse(new Error('Workflow not found'), 'Workflow not found', 404)
}
// Verify workflow belongs to the same workspace
if (workflowRecord.workspaceId !== workspaceId) {
return createMcpErrorResponse(
new Error('Workflow does not belong to this workspace'),
@@ -135,6 +154,7 @@ export const POST = withMcpAuth<RouteParams>('write')(
)
}
// Verify workflow has a valid start block
const hasStartBlock = await hasValidStartBlock(body.workflowId)
if (!hasStartBlock) {
return createMcpErrorResponse(
@@ -144,6 +164,7 @@ export const POST = withMcpAuth<RouteParams>('write')(
)
}
// Check if tool already exists for this workflow
const [existingTool] = await db
.select({ id: workflowMcpTool.id })
.from(workflowMcpTool)
@@ -169,6 +190,7 @@ export const POST = withMcpAuth<RouteParams>('write')(
workflowRecord.description ||
`Execute ${workflowRecord.name} workflow`
// Create the tool
const toolId = crypto.randomUUID()
const [tool] = await db
.insert(workflowMcpTool)

View File

@@ -1,12 +1,10 @@
import { db } from '@sim/db'
import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, inArray, sql } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server'
const logger = createLogger('WorkflowMcpServersAPI')
@@ -27,18 +25,18 @@ export const GET = withMcpAuth('read')(
createdBy: workflowMcpServer.createdBy,
name: workflowMcpServer.name,
description: workflowMcpServer.description,
isPublic: workflowMcpServer.isPublic,
createdAt: workflowMcpServer.createdAt,
updatedAt: workflowMcpServer.updatedAt,
toolCount: sql<number>`(
SELECT COUNT(*)::int
FROM "workflow_mcp_tool"
SELECT COUNT(*)::int
FROM "workflow_mcp_tool"
WHERE "workflow_mcp_tool"."server_id" = "workflow_mcp_server"."id"
)`.as('tool_count'),
})
.from(workflowMcpServer)
.where(eq(workflowMcpServer.workspaceId, workspaceId))
// Fetch all tools for these servers
const serverIds = servers.map((s) => s.id)
const tools =
serverIds.length > 0
@@ -51,6 +49,7 @@ export const GET = withMcpAuth('read')(
.where(inArray(workflowMcpTool.serverId, serverIds))
: []
// Group tool names by server
const toolNamesByServer: Record<string, string[]> = {}
for (const tool of tools) {
if (!toolNamesByServer[tool.serverId]) {
@@ -59,6 +58,7 @@ export const GET = withMcpAuth('read')(
toolNamesByServer[tool.serverId].push(tool.toolName)
}
// Attach tool names to servers
const serversWithToolNames = servers.map((server) => ({
...server,
toolNames: toolNamesByServer[server.id] || [],
@@ -90,7 +90,6 @@ export const POST = withMcpAuth('write')(
logger.info(`[${requestId}] Creating workflow MCP server:`, {
name: body.name,
workspaceId,
workflowIds: body.workflowIds,
})
if (!body.name) {
@@ -111,76 +110,16 @@ export const POST = withMcpAuth('write')(
createdBy: userId,
name: body.name.trim(),
description: body.description?.trim() || null,
isPublic: body.isPublic ?? false,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning()
const workflowIds: string[] = body.workflowIds || []
const addedTools: Array<{ workflowId: string; toolName: string }> = []
if (workflowIds.length > 0) {
const workflows = await db
.select({
id: workflow.id,
name: workflow.name,
description: workflow.description,
isDeployed: workflow.isDeployed,
workspaceId: workflow.workspaceId,
})
.from(workflow)
.where(inArray(workflow.id, workflowIds))
for (const workflowRecord of workflows) {
if (workflowRecord.workspaceId !== workspaceId) {
logger.warn(
`[${requestId}] Skipping workflow ${workflowRecord.id} - does not belong to workspace`
)
continue
}
if (!workflowRecord.isDeployed) {
logger.warn(`[${requestId}] Skipping workflow ${workflowRecord.id} - not deployed`)
continue
}
const hasStartBlock = await hasValidStartBlock(workflowRecord.id)
if (!hasStartBlock) {
logger.warn(`[${requestId}] Skipping workflow ${workflowRecord.id} - no start block`)
continue
}
const toolName = sanitizeToolName(workflowRecord.name)
const toolDescription =
workflowRecord.description || `Execute ${workflowRecord.name} workflow`
const toolId = crypto.randomUUID()
await db.insert(workflowMcpTool).values({
id: toolId,
serverId,
workflowId: workflowRecord.id,
toolName,
toolDescription,
parameterSchema: {},
createdAt: new Date(),
updatedAt: new Date(),
})
addedTools.push({ workflowId: workflowRecord.id, toolName })
}
logger.info(
`[${requestId}] Added ${addedTools.length} tools to server ${serverId}:`,
addedTools.map((t) => t.toolName)
)
}
logger.info(
`[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${serverId})`
)
return createMcpSuccessResponse({ server, addedTools }, 201)
return createMcpSuccessResponse({ server }, 201)
} catch (error) {
logger.error(`[${requestId}] Error creating workflow MCP server:`, error)
return createMcpErrorResponse(

View File

@@ -57,7 +57,6 @@ describe('Scheduled Workflow Execution API Route', () => {
not: vi.fn((condition) => ({ type: 'not', condition })),
isNull: vi.fn((field) => ({ type: 'isNull', field })),
or: vi.fn((...conditions) => ({ type: 'or', conditions })),
sql: vi.fn((strings, ...values) => ({ type: 'sql', strings, values })),
}))
vi.doMock('@sim/db', () => {
@@ -93,17 +92,6 @@ describe('Scheduled Workflow Execution API Route', () => {
status: 'status',
nextRunAt: 'nextRunAt',
lastQueuedAt: 'lastQueuedAt',
deploymentVersionId: 'deploymentVersionId',
},
workflowDeploymentVersion: {
id: 'id',
workflowId: 'workflowId',
isActive: 'isActive',
},
workflow: {
id: 'id',
userId: 'userId',
workspaceId: 'workspaceId',
},
}
})
@@ -146,7 +134,6 @@ describe('Scheduled Workflow Execution API Route', () => {
not: vi.fn((condition) => ({ type: 'not', condition })),
isNull: vi.fn((field) => ({ type: 'isNull', field })),
or: vi.fn((...conditions) => ({ type: 'or', conditions })),
sql: vi.fn((strings, ...values) => ({ type: 'sql', strings, values })),
}))
vi.doMock('@sim/db', () => {
@@ -182,17 +169,6 @@ describe('Scheduled Workflow Execution API Route', () => {
status: 'status',
nextRunAt: 'nextRunAt',
lastQueuedAt: 'lastQueuedAt',
deploymentVersionId: 'deploymentVersionId',
},
workflowDeploymentVersion: {
id: 'id',
workflowId: 'workflowId',
isActive: 'isActive',
},
workflow: {
id: 'id',
userId: 'userId',
workspaceId: 'workspaceId',
},
}
})
@@ -230,7 +206,6 @@ describe('Scheduled Workflow Execution API Route', () => {
not: vi.fn((condition) => ({ type: 'not', condition })),
isNull: vi.fn((field) => ({ type: 'isNull', field })),
or: vi.fn((...conditions) => ({ type: 'or', conditions })),
sql: vi.fn((strings, ...values) => ({ type: 'sql', strings, values })),
}))
vi.doMock('@sim/db', () => {
@@ -253,17 +228,6 @@ describe('Scheduled Workflow Execution API Route', () => {
status: 'status',
nextRunAt: 'nextRunAt',
lastQueuedAt: 'lastQueuedAt',
deploymentVersionId: 'deploymentVersionId',
},
workflowDeploymentVersion: {
id: 'id',
workflowId: 'workflowId',
isActive: 'isActive',
},
workflow: {
id: 'id',
userId: 'userId',
workspaceId: 'workspaceId',
},
}
})
@@ -301,7 +265,6 @@ describe('Scheduled Workflow Execution API Route', () => {
not: vi.fn((condition) => ({ type: 'not', condition })),
isNull: vi.fn((field) => ({ type: 'isNull', field })),
or: vi.fn((...conditions) => ({ type: 'or', conditions })),
sql: vi.fn((strings, ...values) => ({ type: 'sql', strings, values })),
}))
vi.doMock('@sim/db', () => {
@@ -347,17 +310,6 @@ describe('Scheduled Workflow Execution API Route', () => {
status: 'status',
nextRunAt: 'nextRunAt',
lastQueuedAt: 'lastQueuedAt',
deploymentVersionId: 'deploymentVersionId',
},
workflowDeploymentVersion: {
id: 'id',
workflowId: 'workflowId',
isActive: 'isActive',
},
workflow: {
id: 'id',
userId: 'userId',
workspaceId: 'workspaceId',
},
}
})

View File

@@ -1,7 +1,7 @@
import { db, workflowDeploymentVersion, workflowSchedule } from '@sim/db'
import { db, workflowSchedule } from '@sim/db'
import { createLogger } from '@sim/logger'
import { tasks } from '@trigger.dev/sdk'
import { and, eq, isNull, lt, lte, not, or, sql } from 'drizzle-orm'
import { and, eq, isNull, lt, lte, not, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
@@ -37,8 +37,7 @@ export async function GET(request: NextRequest) {
or(
isNull(workflowSchedule.lastQueuedAt),
lt(workflowSchedule.lastQueuedAt, workflowSchedule.nextRunAt)
),
sql`${workflowSchedule.deploymentVersionId} = (select ${workflowDeploymentVersion.id} from ${workflowDeploymentVersion} where ${workflowDeploymentVersion.workflowId} = ${workflowSchedule.workflowId} and ${workflowDeploymentVersion.isActive} = true)`
)
)
)
.returning({

View File

@@ -29,23 +29,12 @@ vi.mock('@sim/db', () => ({
vi.mock('@sim/db/schema', () => ({
workflow: { id: 'id', userId: 'userId', workspaceId: 'workspaceId' },
workflowSchedule: {
workflowId: 'workflowId',
blockId: 'blockId',
deploymentVersionId: 'deploymentVersionId',
},
workflowDeploymentVersion: {
id: 'id',
workflowId: 'workflowId',
isActive: 'isActive',
},
workflowSchedule: { workflowId: 'workflowId', blockId: 'blockId' },
}))
vi.mock('drizzle-orm', () => ({
eq: vi.fn(),
and: vi.fn(),
or: vi.fn(),
isNull: vi.fn(),
}))
vi.mock('@/lib/core/utils/request', () => ({
@@ -67,11 +56,6 @@ function mockDbChain(results: any[]) {
where: () => ({
limit: () => results[callIndex++] || [],
}),
leftJoin: () => ({
where: () => ({
limit: () => results[callIndex++] || [],
}),
}),
}),
}))
}
@@ -90,16 +74,7 @@ describe('Schedule GET API', () => {
it('returns schedule data for authorized user', async () => {
mockDbChain([
[{ userId: 'user-1', workspaceId: null }],
[
{
schedule: {
id: 'sched-1',
cronExpression: '0 9 * * *',
status: 'active',
failedCount: 0,
},
},
],
[{ id: 'sched-1', cronExpression: '0 9 * * *', status: 'active', failedCount: 0 }],
])
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))
@@ -153,7 +128,7 @@ describe('Schedule GET API', () => {
it('allows workspace members to view', async () => {
mockDbChain([
[{ userId: 'other-user', workspaceId: 'ws-1' }],
[{ schedule: { id: 'sched-1', status: 'active', failedCount: 0 } }],
[{ id: 'sched-1', status: 'active', failedCount: 0 }],
])
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))
@@ -164,7 +139,7 @@ describe('Schedule GET API', () => {
it('indicates disabled schedule with failures', async () => {
mockDbChain([
[{ userId: 'user-1', workspaceId: null }],
[{ schedule: { id: 'sched-1', status: 'disabled', failedCount: 100 } }],
[{ id: 'sched-1', status: 'disabled', failedCount: 100 }],
])
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))

View File

@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { workflow, workflowDeploymentVersion, workflowSchedule } from '@sim/db/schema'
import { workflow, workflowSchedule } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, or } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
@@ -62,24 +62,9 @@ export async function GET(req: NextRequest) {
}
const schedule = await db
.select({ schedule: workflowSchedule })
.select()
.from(workflowSchedule)
.leftJoin(
workflowDeploymentVersion,
and(
eq(workflowDeploymentVersion.workflowId, workflowSchedule.workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.where(
and(
...conditions,
or(
eq(workflowSchedule.deploymentVersionId, workflowDeploymentVersion.id),
and(isNull(workflowDeploymentVersion.id), isNull(workflowSchedule.deploymentVersionId))
)
)
)
.where(conditions.length > 1 ? and(...conditions) : conditions[0])
.limit(1)
const headers = new Headers()
@@ -89,7 +74,7 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ schedule: null }, { headers })
}
const scheduleData = schedule[0].schedule
const scheduleData = schedule[0]
const isDisabled = scheduleData.status === 'disabled'
const hasFailures = scheduleData.failedCount > 0

View File

@@ -60,17 +60,7 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
return internalErrorResponse(deployResult.error || 'Failed to deploy workflow')
}
if (!deployResult.deploymentVersionId) {
await undeployWorkflow({ workflowId })
return internalErrorResponse('Failed to resolve deployment version')
}
const scheduleResult = await createSchedulesForDeploy(
workflowId,
normalizedData.blocks,
db,
deployResult.deploymentVersionId
)
const scheduleResult = await createSchedulesForDeploy(workflowId, normalizedData.blocks, db)
if (!scheduleResult.success) {
logger.warn(`Schedule creation failed for workflow ${workflowId}: ${scheduleResult.error}`)
}

View File

@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema'
import { webhook, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq, isNull, or } from 'drizzle-orm'
import { and, desc, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
@@ -71,23 +71,7 @@ export async function GET(request: NextRequest) {
})
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.leftJoin(
workflowDeploymentVersion,
and(
eq(workflowDeploymentVersion.workflowId, workflow.id),
eq(workflowDeploymentVersion.isActive, true)
)
)
.where(
and(
eq(webhook.workflowId, workflowId),
eq(webhook.blockId, blockId),
or(
eq(webhook.deploymentVersionId, workflowDeploymentVersion.id),
and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId))
)
)
)
.where(and(eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId)))
.orderBy(desc(webhook.updatedAt))
logger.info(
@@ -165,23 +149,7 @@ export async function POST(request: NextRequest) {
const existingForBlock = await db
.select({ id: webhook.id, path: webhook.path })
.from(webhook)
.leftJoin(
workflowDeploymentVersion,
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.where(
and(
eq(webhook.workflowId, workflowId),
eq(webhook.blockId, blockId),
or(
eq(webhook.deploymentVersionId, workflowDeploymentVersion.id),
and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId))
)
)
)
.where(and(eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId)))
.limit(1)
if (existingForBlock.length > 0) {
@@ -257,23 +225,7 @@ export async function POST(request: NextRequest) {
const existingForBlock = await db
.select({ id: webhook.id })
.from(webhook)
.leftJoin(
workflowDeploymentVersion,
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.where(
and(
eq(webhook.workflowId, workflowId),
eq(webhook.blockId, blockId),
or(
eq(webhook.deploymentVersionId, workflowDeploymentVersion.id),
and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId))
)
)
)
.where(and(eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId)))
.limit(1)
if (existingForBlock.length > 0) {
targetWebhookId = existingForBlock[0].id

View File

@@ -152,6 +152,7 @@ export async function POST(
const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
requestId,
path,
executionTarget: 'deployed',
})
responses.push(response)
}

View File

@@ -22,13 +22,6 @@ 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)
@@ -41,13 +34,6 @@ 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

View File

@@ -10,11 +10,7 @@ import {
loadWorkflowFromNormalizedTables,
undeployWorkflow,
} from '@/lib/workflows/persistence/utils'
import {
cleanupDeploymentVersion,
createSchedulesForDeploy,
validateWorkflowSchedules,
} from '@/lib/workflows/schedules'
import { createSchedulesForDeploy, validateWorkflowSchedules } from '@/lib/workflows/schedules'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -135,6 +131,22 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return createErrorResponse(`Invalid schedule configuration: ${scheduleValidation.error}`, 400)
}
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
request,
workflowId: id,
workflow: workflowData,
userId: actorUserId,
blocks: normalizedData.blocks,
requestId,
})
if (!triggerSaveResult.success) {
return createErrorResponse(
triggerSaveResult.error?.message || 'Failed to save trigger configuration',
triggerSaveResult.error?.status || 500
)
}
const deployResult = await deployWorkflow({
workflowId: id,
deployedBy: actorUserId,
@@ -146,58 +158,14 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
const deployedAt = deployResult.deployedAt!
const deploymentVersionId = deployResult.deploymentVersionId
if (!deploymentVersionId) {
await undeployWorkflow({ workflowId: id })
return createErrorResponse('Failed to resolve deployment version', 500)
}
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
request,
workflowId: id,
workflow: workflowData,
userId: actorUserId,
blocks: normalizedData.blocks,
requestId,
deploymentVersionId,
})
if (!triggerSaveResult.success) {
await cleanupDeploymentVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId,
})
await undeployWorkflow({ workflowId: id })
return createErrorResponse(
triggerSaveResult.error?.message || 'Failed to save trigger configuration',
triggerSaveResult.error?.status || 500
)
}
let scheduleInfo: { scheduleId?: string; cronExpression?: string; nextRunAt?: Date } = {}
const scheduleResult = await createSchedulesForDeploy(
id,
normalizedData.blocks,
db,
deploymentVersionId
)
const scheduleResult = await createSchedulesForDeploy(id, normalizedData.blocks, db)
if (!scheduleResult.success) {
logger.error(
`[${requestId}] Failed to create schedule for workflow ${id}: ${scheduleResult.error}`
)
await cleanupDeploymentVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId,
})
await undeployWorkflow({ workflowId: id })
return createErrorResponse(scheduleResult.error || 'Failed to create schedule', 500)
}
if (scheduleResult.scheduleId) {
} else if (scheduleResult.scheduleId) {
scheduleInfo = {
scheduleId: scheduleResult.scheduleId,
cronExpression: scheduleResult.cronExpression,

View File

@@ -1,19 +1,10 @@
import { db, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy'
import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils'
import {
cleanupDeploymentVersion,
createSchedulesForDeploy,
validateWorkflowSchedules,
} from '@/lib/workflows/schedules'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import type { BlockState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowActivateDeploymentAPI')
@@ -28,135 +19,30 @@ export async function POST(
const { id, version } = await params
try {
const {
error,
session,
workflow: workflowData,
} = await validateWorkflowPermissions(id, requestId, 'admin')
const { error } = await validateWorkflowPermissions(id, requestId, 'admin')
if (error) {
return createErrorResponse(error.message, error.status)
}
const actorUserId = session?.user?.id
if (!actorUserId) {
logger.warn(`[${requestId}] Unable to resolve actor user for deployment activation: ${id}`)
return createErrorResponse('Unable to determine activating user', 400)
}
const versionNum = Number(version)
if (!Number.isFinite(versionNum)) {
return createErrorResponse('Invalid version number', 400)
}
const [versionRow] = await db
.select({
id: workflowDeploymentVersion.id,
state: workflowDeploymentVersion.state,
})
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.version, versionNum)
)
)
.limit(1)
if (!versionRow?.state) {
return createErrorResponse('Deployment version not found', 404)
}
const [currentActiveVersion] = await db
.select({ id: workflowDeploymentVersion.id })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
const previousVersionId = currentActiveVersion?.id
const deployedState = versionRow.state as { blocks?: Record<string, BlockState> }
const blocks = deployedState.blocks
if (!blocks || typeof blocks !== 'object') {
return createErrorResponse('Invalid deployed state structure', 500)
}
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
request,
workflowId: id,
workflow: workflowData as Record<string, unknown>,
userId: actorUserId,
blocks,
requestId,
deploymentVersionId: versionRow.id,
})
if (!triggerSaveResult.success) {
return createErrorResponse(
triggerSaveResult.error?.message || 'Failed to sync trigger configuration',
triggerSaveResult.error?.status || 500
)
}
const scheduleValidation = validateWorkflowSchedules(blocks)
if (!scheduleValidation.isValid) {
return createErrorResponse(`Invalid schedule configuration: ${scheduleValidation.error}`, 400)
}
const scheduleResult = await createSchedulesForDeploy(id, blocks, db, versionRow.id)
if (!scheduleResult.success) {
await cleanupDeploymentVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId: versionRow.id,
})
return createErrorResponse(scheduleResult.error || 'Failed to sync schedules', 500)
}
const result = await activateWorkflowVersion({ workflowId: id, version: versionNum })
if (!result.success) {
await cleanupDeploymentVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId: versionRow.id,
})
return createErrorResponse(result.error || 'Failed to activate deployment', 400)
}
if (previousVersionId && previousVersionId !== versionRow.id) {
try {
logger.info(
`[${requestId}] Cleaning up previous version ${previousVersionId} webhooks/schedules`
)
await cleanupDeploymentVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId: previousVersionId,
})
logger.info(`[${requestId}] Previous version cleanup completed`)
} catch (cleanupError) {
logger.error(
`[${requestId}] Failed to clean up previous version ${previousVersionId}`,
cleanupError
)
}
if (result.state) {
await syncMcpToolsForWorkflow({
workflowId: id,
requestId,
state: result.state,
context: 'activate',
})
}
await syncMcpToolsForWorkflow({
workflowId: id,
requestId,
state: versionRow.state,
context: 'activate',
})
return createSuccessResponse({ success: true, deployedAt: result.deployedAt })
} catch (error: any) {
logger.error(`[${requestId}] Error activating deployment for workflow: ${id}`, error)

View File

@@ -110,7 +110,6 @@ type AsyncExecutionParams = {
userId: string
input: any
triggerType: CoreTriggerType
preflighted?: boolean
}
/**
@@ -133,7 +132,6 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
userId,
input,
triggerType,
preflighted: params.preflighted,
}
try {
@@ -266,7 +264,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
requestId
)
const shouldPreflightEnvVars = isAsyncMode && isTriggerDevEnabled
const preprocessResult = await preprocessExecution({
workflowId,
userId,
@@ -275,9 +272,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
requestId,
checkDeployment: !shouldUseDraftState,
loggingSession,
preflightEnvVars: shouldPreflightEnvVars,
useDraftState: shouldUseDraftState,
envUserId: isClientSession ? userId : undefined,
})
if (!preprocessResult.success) {
@@ -309,7 +303,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
userId: actorUserId,
input,
triggerType: loggingTriggerType,
preflighted: shouldPreflightEnvVars,
})
}

View File

@@ -77,7 +77,7 @@ export function DeleteChunkModal({
</p>
</ModalBody>
<ModalFooter>
<Button variant='default' disabled={isDeleting} onClick={onClose}>
<Button variant='active' disabled={isDeleting} onClick={onClose}>
Cancel
</Button>
<Button variant='destructive' onClick={handleDeleteChunk} disabled={isDeleting}>

View File

@@ -392,7 +392,7 @@ export function DocumentTagsModal({
return (
<Modal open={open} onOpenChange={handleClose}>
<ModalContent size='sm'>
<ModalContent>
<ModalHeader>
<div className='flex items-center justify-between'>
<span>Document Tags</span>
@@ -486,7 +486,7 @@ export function DocumentTagsModal({
/>
)}
{tagNameConflict && (
<span className='text-[12px] text-[var(--text-error)]'>
<span className='text-[11px] text-[var(--text-error)]'>
A tag with this name already exists
</span>
)}
@@ -639,7 +639,7 @@ export function DocumentTagsModal({
/>
)}
{tagNameConflict && (
<span className='text-[12px] text-[var(--text-error)]'>
<span className='text-[11px] text-[var(--text-error)]'>
A tag with this name already exists
</span>
)}

View File

@@ -48,7 +48,7 @@ import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/componen
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useDocument, useDocumentChunks, useKnowledgeBase } from '@/hooks/kb/use-knowledge'
import { knowledgeKeys, useDocumentChunkSearchQuery } from '@/hooks/queries/knowledge'
import { knowledgeKeys } from '@/hooks/queries/knowledge'
const logger = createLogger('Document')
@@ -313,22 +313,69 @@ export function Document({
isFetching: isFetchingChunks,
} = useDocumentChunks(knowledgeBaseId, documentId, currentPageFromURL)
const {
data: searchResults = [],
isLoading: isLoadingSearch,
error: searchQueryError,
} = useDocumentChunkSearchQuery(
{
knowledgeBaseId,
documentId,
search: debouncedSearchQuery,
},
{
enabled: Boolean(debouncedSearchQuery.trim()),
}
)
const [searchResults, setSearchResults] = useState<ChunkData[]>([])
const [isLoadingSearch, setIsLoadingSearch] = useState(false)
const [searchError, setSearchError] = useState<string | null>(null)
const searchError = searchQueryError instanceof Error ? searchQueryError.message : null
useEffect(() => {
if (!debouncedSearchQuery.trim()) {
setSearchResults([])
setSearchError(null)
return
}
let isMounted = true
const searchAllChunks = async () => {
try {
setIsLoadingSearch(true)
setSearchError(null)
const allResults: ChunkData[] = []
let hasMore = true
let offset = 0
const limit = 100
while (hasMore && isMounted) {
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks?search=${encodeURIComponent(debouncedSearchQuery)}&limit=${limit}&offset=${offset}`
)
if (!response.ok) {
throw new Error('Search failed')
}
const result = await response.json()
if (result.success && result.data) {
allResults.push(...result.data)
hasMore = result.pagination?.hasMore || false
offset += limit
} else {
hasMore = false
}
}
if (isMounted) {
setSearchResults(allResults)
}
} catch (err) {
if (isMounted) {
setSearchError(err instanceof Error ? err.message : 'Search failed')
}
} finally {
if (isMounted) {
setIsLoadingSearch(false)
}
}
}
searchAllChunks()
return () => {
isMounted = false
}
}, [debouncedSearchQuery, knowledgeBaseId, documentId])
const [selectedChunks, setSelectedChunks] = useState<Set<string>>(new Set())
const [selectedChunk, setSelectedChunk] = useState<ChunkData | null>(null)
@@ -1161,19 +1208,15 @@ export function Document({
<ModalHeader>Delete Document</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>
{effectiveDocumentName}
</span>
? This will permanently delete the document and all {documentData?.chunkCount ?? 0}{' '}
chunk
Are you sure you want to delete "{effectiveDocumentName}"? This will permanently
delete the document and all {documentData?.chunkCount ?? 0} chunk
{documentData?.chunkCount === 1 ? '' : 's'} within it.{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
<Button
variant='default'
variant='active'
onClick={() => setShowDeleteDocumentDialog(false)}
disabled={isDeletingDocument}
>

View File

@@ -1523,16 +1523,15 @@ export function KnowledgeBase({
<ModalHeader>Delete Knowledge Base</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{knowledgeBaseName}</span>?
This will permanently delete the knowledge base and all {pagination.total} document
Are you sure you want to delete "{knowledgeBaseName}"? This will permanently delete
the knowledge base and all {pagination.total} document
{pagination.total === 1 ? '' : 's'} within it.{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
<Button
variant='default'
variant='active'
onClick={() => setShowDeleteDialog(false)}
disabled={isDeleting}
>
@@ -1550,16 +1549,14 @@ export function KnowledgeBase({
<ModalHeader>Delete Document</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>
{documents.find((doc) => doc.id === documentToDelete)?.filename ?? 'this document'}
</span>
? <span className='text-[var(--text-error)]'>This action cannot be undone.</span>
Are you sure you want to delete "
{documents.find((doc) => doc.id === documentToDelete)?.filename ?? 'this document'}"?{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
<Button
variant='default'
variant='active'
onClick={() => {
setShowDeleteDocumentModal(false)
setDocumentToDelete(null)
@@ -1585,7 +1582,7 @@ export function KnowledgeBase({
</p>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowBulkDeleteModal(false)}>
<Button variant='active' onClick={() => setShowBulkDeleteModal(false)}>
Cancel
</Button>
<Button variant='destructive' onClick={confirmBulkDelete} disabled={isBulkOperating}>

View File

@@ -221,14 +221,14 @@ export function AddDocumentsModal({
return (
<Modal open={open} onOpenChange={handleClose}>
<ModalContent size='md'>
<ModalContent>
<ModalHeader>Add Documents</ModalHeader>
<ModalBody>
<div className='min-h-0 flex-1 overflow-y-auto'>
<div className='space-y-[12px]'>
{fileError && (
<p className='text-[12px] text-[var(--text-error)] leading-tight'>{fileError}</p>
<p className='text-[11px] text-[var(--text-error)] leading-tight'>{fileError}</p>
)}
<div className='flex flex-col gap-[8px]'>
@@ -336,7 +336,7 @@ export function AddDocumentsModal({
<ModalFooter>
<div className='flex w-full items-center justify-between gap-[12px]'>
{uploadError ? (
<p className='min-w-0 flex-1 truncate text-[12px] text-[var(--text-error)] leading-tight'>
<p className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-error)] leading-tight'>
{uploadError.message}
</p>
) : (

View File

@@ -306,7 +306,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
return (
<>
<Modal open={open} onOpenChange={handleClose}>
<ModalContent size='sm'>
<ModalContent>
<ModalHeader>
<div className='flex items-center justify-between'>
<span>Tags</span>
@@ -400,7 +400,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
}}
/>
{tagNameConflict && (
<span className='text-[12px] text-[var(--text-error)]'>
<span className='text-[11px] text-[var(--text-error)]'>
A tag with this name already exists
</span>
)}
@@ -417,7 +417,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
placeholder='Select type'
/>
{!hasAvailableSlots(createTagForm.fieldType) && (
<span className='text-[12px] text-[var(--text-error)]'>
<span className='text-[11px] text-[var(--text-error)]'>
No available slots for this type. Choose a different type.
</span>
)}

View File

@@ -77,7 +77,7 @@ export function RenameDocumentModal({
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent size='sm'>
<ModalContent>
<ModalHeader>Rename Document</ModalHeader>
<form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>
<ModalBody className='!pb-[16px]'>
@@ -108,7 +108,7 @@ export function RenameDocumentModal({
<ModalFooter>
<div className='flex w-full items-center justify-between gap-[12px]'>
{error ? (
<p className='min-w-0 flex-1 truncate text-[12px] text-[var(--text-error)] leading-tight'>
<p className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-error)] leading-tight'>
{error}
</p>
) : (

View File

@@ -332,7 +332,7 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
return (
<Modal open={open} onOpenChange={handleClose}>
<ModalContent size='lg'>
<ModalContent>
<ModalHeader>Create Knowledge Base</ModalHeader>
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
@@ -528,7 +528,7 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
)}
{fileError && (
<p className='text-[12px] text-[var(--text-error)] leading-tight'>{fileError}</p>
<p className='text-[11px] text-[var(--text-error)] leading-tight'>{fileError}</p>
)}
</div>
</div>
@@ -537,7 +537,7 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
<ModalFooter>
<div className='flex w-full items-center justify-between gap-[12px]'>
{submitStatus?.type === 'error' || uploadError ? (
<p className='min-w-0 flex-1 truncate text-[12px] text-[var(--text-error)] leading-tight'>
<p className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-error)] leading-tight'>
{uploadError?.message || submitStatus?.message}
</p>
) : (

View File

@@ -38,7 +38,7 @@ export function DeleteKnowledgeBaseModal({
}: DeleteKnowledgeBaseModalProps) {
return (
<Modal open={isOpen} onOpenChange={onClose}>
<ModalContent size='sm'>
<ModalContent className='w-[400px]'>
<ModalHeader>Delete Knowledge Base</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
@@ -55,7 +55,7 @@ export function DeleteKnowledgeBaseModal({
</p>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={onClose} disabled={isDeleting}>
<Button variant='active' onClick={onClose} disabled={isDeleting}>
Cancel
</Button>
<Button variant='destructive' onClick={onConfirm} disabled={isDeleting}>

View File

@@ -98,7 +98,7 @@ export function EditKnowledgeBaseModal({
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent size='sm'>
<ModalContent>
<ModalHeader>Edit Knowledge Base</ModalHeader>
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
@@ -118,7 +118,7 @@ export function EditKnowledgeBaseModal({
data-form-type='other'
/>
{errors.name && (
<p className='text-[12px] text-[var(--text-error)]'>{errors.name.message}</p>
<p className='text-[11px] text-[var(--text-error)]'>{errors.name.message}</p>
)}
</div>
@@ -132,7 +132,7 @@ export function EditKnowledgeBaseModal({
className={cn(errors.description && 'border-[var(--text-error)]')}
/>
{errors.description && (
<p className='text-[12px] text-[var(--text-error)]'>
<p className='text-[11px] text-[var(--text-error)]'>
{errors.description.message}
</p>
)}
@@ -143,7 +143,7 @@ export function EditKnowledgeBaseModal({
<ModalFooter>
<div className='flex w-full items-center justify-between gap-[12px]'>
{error ? (
<p className='min-w-0 flex-1 truncate text-[12px] text-[var(--text-error)] leading-tight'>
<p className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-error)] leading-tight'>
{error}
</p>
) : (

View File

@@ -112,7 +112,7 @@ export function SlackChannelSelector({
{selectedChannel.isPrivate ? 'Private' : 'Public'} channel: #{selectedChannel.name}
</p>
)}
{error && <p className='text-[12px] text-[var(--text-error)]'>{error}</p>}
{error && <p className='text-[11px] text-[var(--text-error)]'>{error}</p>}
</div>
)
}

View File

@@ -1,10 +1,9 @@
'use client'
import { useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { X } from 'lucide-react'
import { Badge, Combobox, type ComboboxOption } from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { useWorkflows } from '@/hooks/queries/workflows'
interface WorkflowSelectorProps {
workspaceId: string
@@ -26,9 +25,26 @@ export function WorkflowSelector({
onChange,
error,
}: WorkflowSelectorProps) {
const { data: workflows = [], isPending: isLoading } = useWorkflows(workspaceId, {
syncRegistry: false,
})
const [workflows, setWorkflows] = useState<Array<{ id: string; name: string }>>([])
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const load = async () => {
try {
setIsLoading(true)
const response = await fetch(`/api/workflows?workspaceId=${workspaceId}`)
if (response.ok) {
const data = await response.json()
setWorkflows(data.data || [])
}
} catch {
setWorkflows([])
} finally {
setIsLoading(false)
}
}
load()
}, [workspaceId])
const options: ComboboxOption[] = useMemo(() => {
return workflows.map((w) => ({

View File

@@ -634,7 +634,7 @@ export function NotificationSettings({
}}
/>
{formErrors.webhookUrl && (
<p className='text-[12px] text-[var(--text-error)]'>{formErrors.webhookUrl}</p>
<p className='text-[11px] text-[var(--text-error)]'>{formErrors.webhookUrl}</p>
)}
</div>
<div className='flex flex-col gap-[8px]'>
@@ -660,7 +660,7 @@ export function NotificationSettings({
placeholderWithTags='Add email'
/>
{formErrors.emailRecipients && (
<p className='text-[12px] text-[var(--text-error)]'>{formErrors.emailRecipients}</p>
<p className='text-[11px] text-[var(--text-error)]'>{formErrors.emailRecipients}</p>
)}
</div>
)}
@@ -707,7 +707,7 @@ export function NotificationSettings({
/>
)}
{formErrors.slackAccountId && (
<p className='text-[12px] text-[var(--text-error)]'>
<p className='text-[11px] text-[var(--text-error)]'>
{formErrors.slackAccountId}
</p>
)}
@@ -776,7 +776,7 @@ export function NotificationSettings({
allOptionLabel='All levels'
/>
{formErrors.levelFilter && (
<p className='text-[12px] text-[var(--text-error)]'>{formErrors.levelFilter}</p>
<p className='text-[11px] text-[var(--text-error)]'>{formErrors.levelFilter}</p>
)}
</div>
@@ -822,7 +822,7 @@ export function NotificationSettings({
allOptionLabel='All triggers'
/>
{formErrors.triggerFilter && (
<p className='text-[12px] text-[var(--text-error)]'>{formErrors.triggerFilter}</p>
<p className='text-[11px] text-[var(--text-error)]'>{formErrors.triggerFilter}</p>
)}
</div>
@@ -938,7 +938,7 @@ export function NotificationSettings({
}
/>
{formErrors.consecutiveFailures && (
<p className='text-[12px] text-[var(--text-error)]'>
<p className='text-[11px] text-[var(--text-error)]'>
{formErrors.consecutiveFailures}
</p>
)}
@@ -962,7 +962,7 @@ export function NotificationSettings({
}
/>
{formErrors.failureRatePercent && (
<p className='text-[12px] text-[var(--text-error)]'>
<p className='text-[11px] text-[var(--text-error)]'>
{formErrors.failureRatePercent}
</p>
)}
@@ -982,7 +982,7 @@ export function NotificationSettings({
}
/>
{formErrors.windowHours && (
<p className='text-[12px] text-[var(--text-error)]'>{formErrors.windowHours}</p>
<p className='text-[11px] text-[var(--text-error)]'>{formErrors.windowHours}</p>
)}
</div>
</div>
@@ -1004,7 +1004,7 @@ export function NotificationSettings({
}
/>
{formErrors.durationThresholdMs && (
<p className='text-[12px] text-[var(--text-error)]'>
<p className='text-[11px] text-[var(--text-error)]'>
{formErrors.durationThresholdMs}
</p>
)}
@@ -1028,7 +1028,7 @@ export function NotificationSettings({
}
/>
{formErrors.latencySpikePercent && (
<p className='text-[12px] text-[var(--text-error)]'>
<p className='text-[11px] text-[var(--text-error)]'>
{formErrors.latencySpikePercent}
</p>
)}
@@ -1048,7 +1048,7 @@ export function NotificationSettings({
}
/>
{formErrors.windowHours && (
<p className='text-[12px] text-[var(--text-error)]'>{formErrors.windowHours}</p>
<p className='text-[11px] text-[var(--text-error)]'>{formErrors.windowHours}</p>
)}
</div>
</div>
@@ -1071,7 +1071,7 @@ export function NotificationSettings({
}
/>
{formErrors.costThresholdDollars && (
<p className='text-[12px] text-[var(--text-error)]'>
<p className='text-[11px] text-[var(--text-error)]'>
{formErrors.costThresholdDollars}
</p>
)}
@@ -1094,7 +1094,7 @@ export function NotificationSettings({
}
/>
{formErrors.inactivityHours && (
<p className='text-[12px] text-[var(--text-error)]'>{formErrors.inactivityHours}</p>
<p className='text-[11px] text-[var(--text-error)]'>{formErrors.inactivityHours}</p>
)}
</div>
)}
@@ -1116,7 +1116,7 @@ export function NotificationSettings({
}
/>
{formErrors.errorCountThreshold && (
<p className='text-[12px] text-[var(--text-error)]'>
<p className='text-[11px] text-[var(--text-error)]'>
{formErrors.errorCountThreshold}
</p>
)}
@@ -1136,7 +1136,7 @@ export function NotificationSettings({
}
/>
{formErrors.windowHours && (
<p className='text-[12px] text-[var(--text-error)]'>{formErrors.windowHours}</p>
<p className='text-[11px] text-[var(--text-error)]'>{formErrors.windowHours}</p>
)}
</div>
</div>
@@ -1261,7 +1261,7 @@ export function NotificationSettings({
</Modal>
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent size='sm'>
<ModalContent className='w-[400px]'>
<ModalHeader>Delete Notification</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>

View File

@@ -2,7 +2,6 @@ import { memo, useCallback } from 'react'
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-react'
import { Button, Copy, Tooltip, Trash2 } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -98,7 +97,7 @@ export const ActionBar = memo(
const userPermissions = useUserPermissionsContext()
const isStartBlock = isValidStartBlockType(blockType)
const isStartBlock = blockType === 'starter' || blockType === 'start_trigger'
const isResponseBlock = blockType === 'response'
const isNoteBlock = blockType === 'note'
const isSubflowBlock = blockType === 'loop' || blockType === 'parallel'

View File

@@ -8,7 +8,6 @@ import {
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
/**
* Block information for context menu actions
@@ -74,7 +73,9 @@ export function BlockMenu({
const allEnabled = selectedBlocks.every((b) => b.enabled)
const allDisabled = selectedBlocks.every((b) => !b.enabled)
const hasStarterBlock = selectedBlocks.some((b) => isValidStartBlockType(b.type))
const hasStarterBlock = selectedBlocks.some(
(b) => b.type === 'starter' || b.type === 'start_trigger'
)
const allNoteBlocks = selectedBlocks.every((b) => b.type === 'note')
const isSubflow =
isSingleBlock && (selectedBlocks[0]?.type === 'loop' || selectedBlocks[0]?.type === 'parallel')

View File

@@ -995,7 +995,7 @@ export function Chat() {
<div className='flex items-start gap-2'>
<AlertCircle className='mt-0.5 h-3 w-3 shrink-0 text-[var(--text-error)]' />
<div className='flex-1'>
<div className='mb-1 font-medium text-[12px] text-[var(--text-error)]'>
<div className='mb-1 font-medium text-[11px] text-[var(--text-error)]'>
File upload error
</div>
<div className='space-y-1'>

View File

@@ -9,6 +9,8 @@ import { useCopilotStore, usePanelStore } from '@/stores/panel'
import { useTerminalStore } from '@/stores/terminal'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('DiffControls')
const NOTIFICATION_WIDTH = 240
@@ -17,22 +19,26 @@ const NOTIFICATION_GAP = 16
export const DiffControls = memo(function DiffControls() {
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
const isPanelResizing = usePanelStore((state) => state.isResizing)
const { isDiffReady, hasActiveDiff, acceptChanges, rejectChanges } = useWorkflowDiffStore(
useCallback(
(state) => ({
isDiffReady: state.isDiffReady,
hasActiveDiff: state.hasActiveDiff,
acceptChanges: state.acceptChanges,
rejectChanges: state.rejectChanges,
}),
[]
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 { updatePreviewToolCallState } = useCopilotStore(
const { updatePreviewToolCallState, currentChat, messages } = useCopilotStore(
useCallback(
(state) => ({
updatePreviewToolCallState: state.updatePreviewToolCallState,
currentChat: state.currentChat,
messages: state.messages,
}),
[]
)
@@ -48,6 +54,154 @@ export const DiffControls = memo(function DiffControls() {
return allNotifications.some((n) => !n.workflowId || n.workflowId === activeWorkflowId)
}, [allNotifications, activeWorkflowId])
const createCheckpoint = useCallback(async () => {
if (!activeWorkflowId || !currentChat?.id) {
logger.warn('Cannot create checkpoint: missing workflowId or chatId', {
workflowId: activeWorkflowId,
chatId: currentChat?.id,
})
return false
}
try {
logger.info('Creating checkpoint before accepting changes')
// Use the baseline workflow (state before diff) instead of current state
// This ensures reverting to the checkpoint restores the pre-diff state
const rawState = baselineWorkflow || useWorkflowStore.getState().getWorkflowState()
// The baseline already has merged subblock values, but we'll merge again to be safe
// This ensures all user inputs and subblock data are captured
const blocksWithSubblockValues = mergeSubblockState(rawState.blocks, activeWorkflowId)
// Filter and complete blocks to ensure all required fields are present
// This matches the validation logic from /api/workflows/[id]/state
const filteredBlocks = Object.entries(blocksWithSubblockValues).reduce(
(acc, [blockId, block]) => {
if (block.type && block.name) {
// Ensure all required fields are present
acc[blockId] = {
...block,
id: block.id || blockId, // Ensure id field is set
enabled: block.enabled !== undefined ? block.enabled : true,
horizontalHandles:
block.horizontalHandles !== undefined ? block.horizontalHandles : true,
height: block.height !== undefined ? block.height : 90,
subBlocks: block.subBlocks || {},
outputs: block.outputs || {},
data: block.data || {},
position: block.position || { x: 0, y: 0 }, // Ensure position exists
}
}
return acc
},
{} as typeof rawState.blocks
)
// Clean the workflow state - only include valid fields, exclude null/undefined values
const workflowState = {
blocks: filteredBlocks,
edges: rawState.edges || [],
loops: rawState.loops || {},
parallels: rawState.parallels || {},
lastSaved: rawState.lastSaved || Date.now(),
deploymentStatuses: rawState.deploymentStatuses || {},
}
logger.info('Prepared complete workflow state for checkpoint', {
blocksCount: Object.keys(workflowState.blocks).length,
edgesCount: workflowState.edges.length,
loopsCount: Object.keys(workflowState.loops).length,
parallelsCount: Object.keys(workflowState.parallels).length,
hasRequiredFields: Object.values(workflowState.blocks).every(
(block) => block.id && block.type && block.name && block.position
),
hasSubblockValues: Object.values(workflowState.blocks).some((block) =>
Object.values(block.subBlocks || {}).some(
(subblock) => subblock.value !== null && subblock.value !== undefined
)
),
sampleBlock: Object.values(workflowState.blocks)[0],
})
// Find the most recent user message ID from the current chat
const userMessages = messages.filter((msg) => msg.role === 'user')
const lastUserMessage = userMessages[userMessages.length - 1]
const messageId = lastUserMessage?.id
logger.info('Creating checkpoint with message association', {
totalMessages: messages.length,
userMessageCount: userMessages.length,
lastUserMessageId: messageId,
chatId: currentChat.id,
entireMessageArray: messages,
allMessageIds: messages.map((m) => ({
id: m.id,
role: m.role,
content: m.content.substring(0, 50),
})),
selectedUserMessages: userMessages.map((m) => ({
id: m.id,
content: m.content.substring(0, 100),
})),
allRawMessageIds: messages.map((m) => m.id),
userMessageIds: userMessages.map((m) => m.id),
checkpointData: {
workflowId: activeWorkflowId,
chatId: currentChat.id,
messageId: messageId,
messageFound: !!lastUserMessage,
},
})
const response = await fetch('/api/copilot/checkpoints', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workflowId: activeWorkflowId,
chatId: currentChat.id,
messageId,
workflowState: JSON.stringify(workflowState),
}),
})
if (!response.ok) {
throw new Error(`Failed to create checkpoint: ${response.statusText}`)
}
const result = await response.json()
const newCheckpoint = result.checkpoint
logger.info('Checkpoint created successfully', {
messageId,
chatId: currentChat.id,
checkpointId: newCheckpoint?.id,
})
// Update the copilot store immediately to show the checkpoint icon
if (newCheckpoint && messageId) {
const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState()
const existingCheckpoints = currentCheckpoints[messageId] || []
const updatedCheckpoints = {
...currentCheckpoints,
[messageId]: [newCheckpoint, ...existingCheckpoints],
}
useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints })
logger.info('Updated copilot store with new checkpoint', {
messageId,
checkpointId: newCheckpoint.id,
})
}
return true
} catch (error) {
logger.error('Failed to create checkpoint:', error)
return false
}
}, [activeWorkflowId, currentChat, messages, baselineWorkflow])
const handleAccept = useCallback(() => {
logger.info('Accepting proposed changes with backup protection')
@@ -84,8 +238,12 @@ 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')
}, [updatePreviewToolCallState, acceptChanges])
}, [createCheckpoint, updatePreviewToolCallState, acceptChanges])
const handleReject = useCallback(() => {
logger.info('Rejecting proposed changes (optimistic)')

View File

@@ -1,5 +1,4 @@
import { memo, useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/core/utils/cn'
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
/**
@@ -7,23 +6,14 @@ import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId
*/
const CHARACTER_DELAY = 3
/**
* Props for the StreamingIndicator component
*/
interface StreamingIndicatorProps {
/** Optional class name for layout adjustments */
className?: string
}
/**
* StreamingIndicator shows animated dots during message streaming
* Used as a standalone indicator when no content has arrived yet
*
* @param props - Component props
* @returns Animated loading indicator
*/
export const StreamingIndicator = memo(({ className }: StreamingIndicatorProps) => (
<div className={cn('flex h-[1.25rem] items-center text-muted-foreground', className)}>
export const StreamingIndicator = memo(() => (
<div className='flex h-[1.25rem] items-center text-muted-foreground'>
<div className='flex space-x-0.5'>
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms] [animation-duration:1.2s]' />
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:150ms] [animation-duration:1.2s]' />

View File

@@ -1,20 +1,10 @@
'use client'
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import { memo, useEffect, 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(/&lt;\/?thinking[^&]*&gt;/gi, '')
.trim()
}
/**
* Max height for thinking content before internal scrolling kicks in
*/
@@ -197,9 +187,6 @@ 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)
@@ -222,10 +209,10 @@ export function ThinkingBlock({
return
}
if (!userCollapsedRef.current && cleanContent && cleanContent.length > 0) {
if (!userCollapsedRef.current && content && content.trim().length > 0) {
setIsExpanded(true)
}
}, [isStreaming, cleanContent, hasFollowingContent, hasSpecialTags])
}, [isStreaming, content, hasFollowingContent, hasSpecialTags])
// Reset start time when streaming begins
useEffect(() => {
@@ -311,7 +298,7 @@ export function ThinkingBlock({
return `${seconds}s`
}
const hasContent = cleanContent.length > 0
const hasContent = content && content.trim().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)}`
@@ -387,10 +374,7 @@ export function ThinkingBlock({
isExpanded ? 'mt-1.5 max-h-[150px] opacity-100' : 'max-h-0 opacity-0'
)}
>
<SmoothThinkingText
content={cleanContent}
isStreaming={isStreaming && !hasFollowingContent}
/>
<SmoothThinkingText content={content} isStreaming={isStreaming && !hasFollowingContent} />
</div>
</div>
)
@@ -428,7 +412,7 @@ export function ThinkingBlock({
>
{/* Completed thinking text - dimmed with markdown */}
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.4] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 font-season text-[12px] text-[var(--text-muted)]'>
<CopilotMarkdownRenderer content={cleanContent} />
<CopilotMarkdownRenderer content={content} />
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
'use client'
import { type FC, memo, useCallback, useMemo, useRef, useState } from 'react'
import { type FC, memo, useCallback, useMemo, useState } from 'react'
import { RotateCcw } from 'lucide-react'
import { Button } from '@/components/emcn'
import {
@@ -93,8 +93,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
// UI state
const [isHoveringMessage, setIsHoveringMessage] = useState(false)
const cancelEditRef = useRef<(() => void) | null>(null)
// Checkpoint management hook
const {
showRestoreConfirmation,
@@ -114,8 +112,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
messages,
messageCheckpoints,
onRevertModeChange,
onEditModeChange,
() => cancelEditRef.current?.()
onEditModeChange
)
// Message editing hook
@@ -145,8 +142,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
pendingEditRef,
})
cancelEditRef.current = handleCancelEdit
// Get clean text content with double newline parsing
const cleanTextContent = useMemo(() => {
if (!message.content) return ''
@@ -493,9 +488,8 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
{/* Content blocks in chronological order */}
{memoizedContentBlocks}
{isStreaming && (
<StreamingIndicator className={!hasVisibleContent ? 'mt-1' : undefined} />
)}
{/* Streaming indicator always at bottom during streaming */}
{isStreaming && <StreamingIndicator />}
{message.errorType === 'usage_limit' && (
<div className='flex gap-1.5'>

View File

@@ -22,8 +22,7 @@ export function useCheckpointManagement(
messages: CopilotMessage[],
messageCheckpoints: any[],
onRevertModeChange?: (isReverting: boolean) => void,
onEditModeChange?: (isEditing: boolean) => void,
onCancelEdit?: () => void
onEditModeChange?: (isEditing: boolean) => void
) {
const [showRestoreConfirmation, setShowRestoreConfirmation] = useState(false)
const [showCheckpointDiscardModal, setShowCheckpointDiscardModal] = useState(false)
@@ -58,7 +57,7 @@ export function useCheckpointManagement(
const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState()
const updatedCheckpoints = {
...currentCheckpoints,
[message.id]: [],
[message.id]: messageCheckpoints.slice(1),
}
useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints })
@@ -94,6 +93,7 @@ export function useCheckpointManagement(
setShowRestoreConfirmation(false)
onRevertModeChange?.(false)
onEditModeChange?.(true)
logger.info('Checkpoint reverted and removed from message', {
messageId: message.id,
@@ -114,6 +114,7 @@ export function useCheckpointManagement(
messages,
currentChat,
onRevertModeChange,
onEditModeChange,
])
/**
@@ -139,7 +140,7 @@ export function useCheckpointManagement(
const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState()
const updatedCheckpoints = {
...currentCheckpoints,
[message.id]: [],
[message.id]: messageCheckpoints.slice(1),
}
useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints })
@@ -153,8 +154,6 @@ export function useCheckpointManagement(
}
setShowCheckpointDiscardModal(false)
onEditModeChange?.(false)
onCancelEdit?.()
const { sendMessage } = useCopilotStore.getState()
if (pendingEditRef.current) {
@@ -174,7 +173,6 @@ export function useCheckpointManagement(
fileAttachments: fileAttachments || message.fileAttachments,
contexts: contexts || (message as any).contexts,
messageId: message.id,
queueIfBusy: false,
})
}
pendingEditRef.current = null
@@ -182,17 +180,15 @@ export function useCheckpointManagement(
} finally {
setIsProcessingDiscard(false)
}
}, [messageCheckpoints, revertToCheckpoint, message, messages, onEditModeChange, onCancelEdit])
}, [messageCheckpoints, revertToCheckpoint, message, messages])
/**
* Cancels checkpoint discard and clears pending edit
*/
const handleCancelCheckpointDiscard = useCallback(() => {
setShowCheckpointDiscardModal(false)
onEditModeChange?.(false)
onCancelEdit?.()
pendingEditRef.current = null
}, [onEditModeChange, onCancelEdit])
}, [])
/**
* Continues with edit WITHOUT reverting checkpoint
@@ -218,12 +214,11 @@ export function useCheckpointManagement(
fileAttachments: fileAttachments || message.fileAttachments,
contexts: contexts || (message as any).contexts,
messageId: message.id,
queueIfBusy: false,
})
}
pendingEditRef.current = null
}
}, [message, messages, onEditModeChange, onCancelEdit])
}, [message, messages])
/**
* Handles keyboard events for restore confirmation (Escape/Enter)

View File

@@ -166,7 +166,6 @@ export function useMessageEditing(props: UseMessageEditingProps) {
fileAttachments: fileAttachments || message.fileAttachments,
contexts: contexts || (message as any).contexts,
messageId: message.id,
queueIfBusy: false,
})
}
},

View File

@@ -1446,10 +1446,8 @@ function WorkflowEditSummary({ toolCall }: { toolCall: CopilotToolCall }) {
blockType = blockType || op.block_type || ''
}
if (!blockName) blockName = blockType || ''
if (!blockName && !blockType) {
continue
}
// Fallback name to type or ID
if (!blockName) blockName = blockType || blockId
const change: BlockChange = { blockId, blockName, blockType }

View File

@@ -22,9 +22,6 @@ interface UseContextManagementProps {
export function useContextManagement({ message, initialContexts }: UseContextManagementProps) {
const [selectedContexts, setSelectedContexts] = useState<ChatContext[]>(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(() => {
@@ -81,10 +78,10 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
// Check for slash command tokens or mention tokens based on kind
const isSlashCommand = c.kind === 'slash_command'
const prefix = isSlashCommand ? '/' : '@'
const tokenPattern = new RegExp(
`(^|\\s)${escapeRegex(prefix)}${escapeRegex(c.label)}(\\s|$)`
)
return tokenPattern.test(message)
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)
})
return filtered.length === prev.length ? prev : filtered
})

View File

@@ -76,15 +76,6 @@ 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)

View File

@@ -613,7 +613,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const insertTriggerAndOpenMenu = useCallback(
(trigger: '@' | '/') => {
if (disabled) return
if (disabled || isLoading) return
const textarea = mentionMenu.textareaRef.current
if (!textarea) return
@@ -642,7 +642,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}
mentionMenu.setSubmenuActiveIndex(0)
},
[disabled, mentionMenu, message, setMessage]
[disabled, isLoading, mentionMenu, message, setMessage]
)
const handleOpenMentionMenuWithAt = useCallback(
@@ -737,7 +737,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
title='Insert @'
className={cn(
'cursor-pointer rounded-[6px] p-[4.5px]',
disabled && 'cursor-not-allowed'
(disabled || isLoading) && 'cursor-not-allowed'
)}
>
<AtSign className='h-3 w-3' strokeWidth={1.75} />
@@ -749,7 +749,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
title='Insert /'
className={cn(
'cursor-pointer rounded-[6px] p-[4.5px]',
disabled && 'cursor-not-allowed'
(disabled || isLoading) && 'cursor-not-allowed'
)}
>
<span className='flex h-3 w-3 items-center justify-center font-medium text-[11px] leading-none'>
@@ -816,7 +816,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
placeholder={fileAttachments.isDragging ? 'Drop files here...' : effectivePlaceholder}
disabled={disabled}
rows={2}
className='relative z-[2] m-0 box-border h-auto max-h-[120px] min-h-[48px] w-full resize-none overflow-y-auto overflow-x-hidden break-words border-0 bg-transparent px-[2px] py-1 font-medium font-sans text-sm text-transparent leading-[1.25rem] caret-foreground outline-none [-ms-overflow-style:none] [scrollbar-width:none] [text-rendering:auto] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0 dark:placeholder:text-[var(--text-muted)] [&::-webkit-scrollbar]:hidden'
className='relative z-[2] m-0 box-border h-auto min-h-[48px] w-full resize-none overflow-y-auto overflow-x-hidden break-words border-0 bg-transparent px-[2px] py-1 font-medium font-sans text-sm text-transparent leading-[1.25rem] caret-foreground outline-none [-ms-overflow-style:none] [scrollbar-width:none] [text-rendering:auto] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0 dark:placeholder:text-[var(--text-muted)] [&::-webkit-scrollbar]:hidden'
/>
{/* Mention Menu Portal */}

View File

@@ -83,7 +83,8 @@ interface A2aDeployProps {
workflowNeedsRedeployment?: boolean
onSubmittingChange?: (submitting: boolean) => void
onCanSaveChange?: (canSave: boolean) => void
/** Callback for when republish status changes - depends on local form state */
onAgentExistsChange?: (exists: boolean) => void
onPublishedChange?: (published: boolean) => void
onNeedsRepublishChange?: (needsRepublish: boolean) => void
onDeployWorkflow?: () => Promise<void>
}
@@ -98,6 +99,8 @@ export function A2aDeploy({
workflowNeedsRedeployment,
onSubmittingChange,
onCanSaveChange,
onAgentExistsChange,
onPublishedChange,
onNeedsRepublishChange,
onDeployWorkflow,
}: A2aDeployProps) {
@@ -233,6 +236,14 @@ export function A2aDeploy({
}
}, [existingAgent, workflowName, workflowDescription])
useEffect(() => {
onAgentExistsChange?.(!!existingAgent)
}, [existingAgent, onAgentExistsChange])
useEffect(() => {
onPublishedChange?.(existingAgent?.isPublished ?? false)
}, [existingAgent?.isPublished, onPublishedChange])
const hasFormChanges = useMemo(() => {
if (!existingAgent) return false
const savedSchemes = existingAgent.authentication?.schemes || []

View File

@@ -29,11 +29,9 @@ import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/compo
import {
type AuthType,
type ChatFormData,
useCreateChat,
useDeleteChat,
useUpdateChat,
} from '@/hooks/queries/chats'
import { useIdentifierValidation } from './hooks'
useChatDeployment,
useIdentifierValidation,
} from './hooks'
const logger = createLogger('ChatDeploy')
@@ -47,6 +45,7 @@ interface ChatDeployProps {
existingChat: ExistingChat | null
isLoadingChat: boolean
onRefetchChat: () => Promise<void>
onChatExistsChange?: (exists: boolean) => void
chatSubmitting: boolean
setChatSubmitting: (submitting: boolean) => void
onValidationChange?: (isValid: boolean) => void
@@ -98,6 +97,7 @@ export function ChatDeploy({
existingChat,
isLoadingChat,
onRefetchChat,
onChatExistsChange,
chatSubmitting,
setChatSubmitting,
onValidationChange,
@@ -121,11 +121,8 @@ export function ChatDeploy({
const [formData, setFormData] = useState<ChatFormData>(initialFormData)
const [errors, setErrors] = useState<FormErrors>({})
const { deployChat } = useChatDeployment()
const formRef = useRef<HTMLFormElement>(null)
const createChatMutation = useCreateChat()
const updateChatMutation = useUpdateChat()
const deleteChatMutation = useDeleteChat()
const [isIdentifierValid, setIsIdentifierValid] = useState(false)
const [hasInitializedForm, setHasInitializedForm] = useState(false)
@@ -234,26 +231,15 @@ export function ChatDeploy({
return
}
let chatUrl: string
if (existingChat?.id) {
const result = await updateChatMutation.mutateAsync({
chatId: existingChat.id,
workflowId,
formData,
imageUrl,
})
chatUrl = result.chatUrl
} else {
const result = await createChatMutation.mutateAsync({
workflowId,
formData,
apiKey: deploymentInfo?.apiKey,
imageUrl,
})
chatUrl = result.chatUrl
}
const chatUrl = await deployChat(
workflowId,
formData,
deploymentInfo,
existingChat?.id,
imageUrl
)
onChatExistsChange?.(true)
onDeployed?.()
onVersionActivated?.()
@@ -280,13 +266,18 @@ export function ChatDeploy({
try {
setIsDeleting(true)
await deleteChatMutation.mutateAsync({
chatId: existingChat.id,
workflowId,
const response = await fetch(`/api/chat/manage/${existingChat.id}`, {
method: 'DELETE',
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to delete chat')
}
setImageUrl(null)
setHasInitializedForm(false)
onChatExistsChange?.(false)
await onRefetchChat()
onDeploymentComplete?.()
@@ -557,7 +548,7 @@ function IdentifierInput({
)}
</div>
</div>
{error && <p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>{error}</p>}
{error && <p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>{error}</p>}
<p className='mt-[6.5px] truncate text-[11px] text-[var(--text-secondary)]'>
{isEditingExisting && value ? (
<>
@@ -777,7 +768,7 @@ function AuthSelector({
disabled={disabled}
/>
{emailError && (
<p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>{emailError}</p>
<p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>{emailError}</p>
)}
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
{authType === 'email'
@@ -787,7 +778,7 @@ function AuthSelector({
</div>
)}
{error && <p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>{error}</p>}
{error && <p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>{error}</p>}
</div>
)
}

View File

@@ -1 +1,2 @@
export { type AuthType, type ChatFormData, useChatDeployment } from './use-chat-deployment'
export { useIdentifierValidation } from './use-identifier-validation'

View File

@@ -0,0 +1,131 @@
import { useCallback } from 'react'
import { createLogger } from '@sim/logger'
import { z } from 'zod'
import type { OutputConfig } from '@/stores/chat/types'
const logger = createLogger('ChatDeployment')
export type AuthType = 'public' | 'password' | 'email' | 'sso'
export interface ChatFormData {
identifier: string
title: string
description: string
authType: AuthType
password: string
emails: string[]
welcomeMessage: string
selectedOutputBlocks: string[]
}
const chatSchema = z.object({
workflowId: z.string().min(1, 'Workflow ID is required'),
identifier: z
.string()
.min(1, 'Identifier is required')
.regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens'),
title: z.string().min(1, 'Title is required'),
description: z.string().optional(),
customizations: z.object({
primaryColor: z.string(),
welcomeMessage: z.string(),
imageUrl: z.string().optional(),
}),
authType: z.enum(['public', 'password', 'email', 'sso']).default('public'),
password: z.string().optional(),
allowedEmails: z.array(z.string()).optional().default([]),
outputConfigs: z
.array(
z.object({
blockId: z.string(),
path: z.string(),
})
)
.optional()
.default([]),
})
/**
* Parses output block selections into structured output configs
*/
function parseOutputConfigs(selectedOutputBlocks: string[]): OutputConfig[] {
return selectedOutputBlocks
.map((outputId) => {
const firstUnderscoreIndex = outputId.indexOf('_')
if (firstUnderscoreIndex !== -1) {
const blockId = outputId.substring(0, firstUnderscoreIndex)
const path = outputId.substring(firstUnderscoreIndex + 1)
if (blockId && path) {
return { blockId, path }
}
}
return null
})
.filter((config): config is OutputConfig => config !== null)
}
/**
* Hook for deploying or updating a chat interface
*/
export function useChatDeployment() {
const deployChat = useCallback(
async (
workflowId: string,
formData: ChatFormData,
deploymentInfo: { apiKey: string } | null,
existingChatId?: string,
imageUrl?: string | null
): Promise<string> => {
const outputConfigs = parseOutputConfigs(formData.selectedOutputBlocks)
const payload = {
workflowId,
identifier: formData.identifier.trim(),
title: formData.title.trim(),
description: formData.description.trim(),
customizations: {
primaryColor: 'var(--brand-primary-hover-hex)',
welcomeMessage: formData.welcomeMessage.trim(),
...(imageUrl && { imageUrl }),
},
authType: formData.authType,
password: formData.authType === 'password' ? formData.password : undefined,
allowedEmails:
formData.authType === 'email' || formData.authType === 'sso' ? formData.emails : [],
outputConfigs,
apiKey: deploymentInfo?.apiKey,
deployApiEnabled: !existingChatId,
}
chatSchema.parse(payload)
const endpoint = existingChatId ? `/api/chat/manage/${existingChatId}` : '/api/chat'
const method = existingChatId ? 'PATCH' : 'POST'
const response = await fetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const result = await response.json()
if (!response.ok) {
if (result.error === 'Identifier already in use') {
throw new Error('This identifier is already in use')
}
throw new Error(result.error || `Failed to ${existingChatId ? 'update' : 'deploy'} chat`)
}
if (!result.chatUrl) {
throw new Error('Response missing chatUrl')
}
logger.info(`Chat ${existingChatId ? 'updated' : 'deployed'} successfully:`, result.chatUrl)
return result.chatUrl
},
[]
)
return { deployChat }
}

View File

@@ -216,7 +216,7 @@ export function FormBuilder({
)}
</div>
{titleError && (
<p className='mt-[4px] text-[12px] text-[var(--text-error)]'>{titleError}</p>
<p className='mt-[4px] text-[11px] text-[var(--text-error)]'>{titleError}</p>
)}
<div className='mt-[4px] flex items-center gap-[6px]'>
<input

View File

@@ -17,18 +17,11 @@ import { Skeleton } from '@/components/ui'
import { isDev } from '@/lib/core/config/feature-flags'
import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl, getEmailDomain } from '@/lib/core/utils/urls'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import {
type FieldConfig,
useCreateForm,
useDeleteForm,
useFormByWorkflow,
useUpdateForm,
} from '@/hooks/queries/forms'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { EmbedCodeGenerator } from './components/embed-code-generator'
import { FormBuilder } from './components/form-builder'
import { useFormDeployment } from './hooks/use-form-deployment'
import { useIdentifierValidation } from './hooks/use-identifier-validation'
const logger = createLogger('FormDeploy')
@@ -41,11 +34,38 @@ interface FormErrors {
general?: string
}
interface FieldConfig {
name: string
type: string
label: string
description?: string
required?: boolean
}
export interface ExistingForm {
id: string
identifier: string
title: string
description?: string
customizations: {
primaryColor?: string
thankYouMessage?: string
logoUrl?: string
fieldConfigs?: FieldConfig[]
}
authType: 'public' | 'password' | 'email'
hasPassword?: boolean
allowedEmails?: string[]
showBranding: boolean
isActive: boolean
}
interface FormDeployProps {
workflowId: string
onDeploymentComplete?: () => void
onValidationChange?: (isValid: boolean) => void
onSubmittingChange?: (isSubmitting: boolean) => void
onExistingFormChange?: (exists: boolean) => void
formSubmitting?: boolean
setFormSubmitting?: (submitting: boolean) => void
onDeployed?: () => Promise<void>
@@ -61,6 +81,7 @@ export function FormDeploy({
onDeploymentComplete,
onValidationChange,
onSubmittingChange,
onExistingFormChange,
formSubmitting,
setFormSubmitting,
onDeployed,
@@ -74,6 +95,8 @@ export function FormDeploy({
const [authType, setAuthType] = useState<'public' | 'password' | 'email'>('public')
const [password, setPassword] = useState('')
const [emailItems, setEmailItems] = useState<TagItem[]>([])
const [existingForm, setExistingForm] = useState<ExistingForm | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [formUrl, setFormUrl] = useState('')
const [inputFields, setInputFields] = useState<{ name: string; type: string }[]>([])
const [showPasswordField, setShowPasswordField] = useState(false)
@@ -81,12 +104,7 @@ export function FormDeploy({
const [errors, setErrors] = useState<FormErrors>({})
const [isIdentifierValid, setIsIdentifierValid] = useState(false)
const { data: existingForm, isLoading } = useFormByWorkflow(workflowId)
const createFormMutation = useCreateForm()
const updateFormMutation = useUpdateForm()
const deleteFormMutation = useDeleteForm()
const isSubmitting = createFormMutation.isPending || updateFormMutation.isPending
const { createForm, updateForm, deleteForm, isSubmitting } = useFormDeployment()
const {
isChecking: isCheckingIdentifier,
@@ -106,54 +124,85 @@ export function FormDeploy({
setErrors((prev) => ({ ...prev, [field]: undefined }))
}
// Populate form fields when existing form data is loaded
// Fetch existing form deployment
useEffect(() => {
if (existingForm) {
setIdentifier(existingForm.identifier)
setTitle(existingForm.title)
setDescription(existingForm.description || '')
setThankYouMessage(
existingForm.customizations?.thankYouMessage ||
'Your response has been submitted successfully.'
)
setAuthType(existingForm.authType)
setEmailItems(
(existingForm.allowedEmails || []).map((email) => ({ value: email, isValid: true }))
)
if (existingForm.customizations?.fieldConfigs) {
setFieldConfigs(existingForm.customizations.fieldConfigs)
}
async function fetchExistingForm() {
if (!workflowId) return
const baseUrl = getBaseUrl()
try {
const url = new URL(baseUrl)
let host = url.host
if (host.startsWith('www.')) host = host.substring(4)
setFormUrl(`${url.protocol}//${host}/form/${existingForm.identifier}`)
} catch {
setFormUrl(
isDev
? `http://localhost:3000/form/${existingForm.identifier}`
: `https://sim.ai/form/${existingForm.identifier}`
)
}
} else if (!isLoading) {
const workflowName =
useWorkflowStore.getState().blocks[Object.keys(useWorkflowStore.getState().blocks)[0]]
?.name || 'Form'
setTitle(`${workflowName} Form`)
}
}, [existingForm, isLoading])
setIsLoading(true)
const response = await fetch(`/api/workflows/${workflowId}/form/status`)
if (response.ok) {
const data = await response.json()
if (data.isDeployed && data.form) {
const detailResponse = await fetch(`/api/form/manage/${data.form.id}`)
if (detailResponse.ok) {
const formDetail = await detailResponse.json()
const form = formDetail.form as ExistingForm
setExistingForm(form)
onExistingFormChange?.(true)
setIdentifier(form.identifier)
setTitle(form.title)
setDescription(form.description || '')
setThankYouMessage(
form.customizations?.thankYouMessage ||
'Your response has been submitted successfully.'
)
setAuthType(form.authType)
setEmailItems(
(form.allowedEmails || []).map((email) => ({ value: email, isValid: true }))
)
if (form.customizations?.fieldConfigs) {
setFieldConfigs(form.customizations.fieldConfigs)
}
const baseUrl = getBaseUrl()
try {
const url = new URL(baseUrl)
let host = url.host
if (host.startsWith('www.')) host = host.substring(4)
setFormUrl(`${url.protocol}//${host}/form/${form.identifier}`)
} catch {
setFormUrl(
isDev
? `http://localhost:3000/form/${form.identifier}`
: `https://sim.ai/form/${form.identifier}`
)
}
}
} else {
setExistingForm(null)
onExistingFormChange?.(false)
const workflowName =
useWorkflowStore.getState().blocks[Object.keys(useWorkflowStore.getState().blocks)[0]]
?.name || 'Form'
setTitle(`${workflowName} Form`)
}
}
} catch (err) {
logger.error('Error fetching form deployment:', err)
} finally {
setIsLoading(false)
}
}
fetchExistingForm()
}, [workflowId, onExistingFormChange])
// Get input fields from start block and initialize field configs
useEffect(() => {
const blocks = Object.values(useWorkflowStore.getState().blocks)
const startBlock = blocks.find((b) => isValidStartBlockType(b.type))
const startBlock = blocks.find((b) => b.type === 'starter' || b.type === 'start_trigger')
if (startBlock) {
const inputFormat = useSubBlockStore.getState().getValue(startBlock.id, 'inputFormat')
if (inputFormat && Array.isArray(inputFormat)) {
setInputFields(inputFormat)
// Initialize field configs if not already set
if (fieldConfigs.length === 0) {
setFieldConfigs(
inputFormat.map((f: { name: string; type?: string }) => ({
@@ -173,6 +222,7 @@ export function FormDeploy({
const allowedEmails = emailItems.filter((item) => item.isValid).map((item) => item.value)
// Validate form
useEffect(() => {
const isValid =
inputFields.length > 0 &&
@@ -203,6 +253,7 @@ export function FormDeploy({
e.preventDefault()
setErrors({})
// Validate before submit
if (!isIdentifierValid && identifier !== existingForm?.identifier) {
setError('identifier', 'Please wait for identifier validation to complete')
return
@@ -230,21 +281,17 @@ export function FormDeploy({
try {
if (existingForm) {
await updateFormMutation.mutateAsync({
formId: existingForm.id,
workflowId,
data: {
identifier,
title,
description,
customizations,
authType,
password: password || undefined,
allowedEmails,
},
await updateForm(existingForm.id, {
identifier,
title,
description,
customizations,
authType,
password: password || undefined,
allowedEmails,
})
} else {
const result = await createFormMutation.mutateAsync({
const result = await createForm({
workflowId,
identifier,
title,
@@ -257,6 +304,7 @@ export function FormDeploy({
if (result?.formUrl) {
setFormUrl(result.formUrl)
// Open the form in a new window after successful deployment
window.open(result.formUrl, '_blank', 'noopener,noreferrer')
}
}
@@ -270,6 +318,7 @@ export function FormDeploy({
const message = err instanceof Error ? err.message : 'An error occurred'
logger.error('Error deploying form:', err)
// Parse error message and show inline
if (message.toLowerCase().includes('identifier')) {
setError('identifier', message)
} else if (message.toLowerCase().includes('password')) {
@@ -293,8 +342,8 @@ export function FormDeploy({
password,
allowedEmails,
isIdentifierValid,
createFormMutation,
updateFormMutation,
createForm,
updateForm,
onDeployed,
onDeploymentComplete,
]
@@ -304,10 +353,9 @@ export function FormDeploy({
if (!existingForm) return
try {
await deleteFormMutation.mutateAsync({
formId: existingForm.id,
workflowId,
})
await deleteForm(existingForm.id)
setExistingForm(null)
onExistingFormChange?.(false)
setIdentifier('')
setTitle('')
setDescription('')
@@ -315,7 +363,7 @@ export function FormDeploy({
} catch (err) {
logger.error('Error deleting form:', err)
}
}, [existingForm, deleteFormMutation, workflowId])
}, [existingForm, deleteForm, onExistingFormChange])
if (isLoading) {
return (
@@ -399,7 +447,7 @@ export function FormDeploy({
</div>
</div>
{(identifierError || errors.identifier) && (
<p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>
<p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>
{identifierError || errors.identifier}
</p>
)}
@@ -483,7 +531,7 @@ export function FormDeploy({
</button>
</div>
{errors.password && (
<p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>{errors.password}</p>
<p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>{errors.password}</p>
)}
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
{existingForm?.hasPassword
@@ -520,7 +568,7 @@ export function FormDeploy({
placeholderWithTags='Add another'
/>
{errors.emails && (
<p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>{errors.emails}</p>
<p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>{errors.emails}</p>
)}
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
Add specific emails or entire domains (@example.com)
@@ -551,7 +599,7 @@ export function FormDeploy({
)}
{errors.general && (
<p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>{errors.general}</p>
<p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>{errors.general}</p>
)}
<button type='button' data-delete-trigger onClick={handleDelete} className='hidden' />

View File

@@ -0,0 +1,151 @@
import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
const logger = createLogger('useFormDeployment')
interface CreateFormParams {
workflowId: string
identifier: string
title: string
description?: string
customizations?: {
primaryColor?: string
welcomeMessage?: string
thankYouTitle?: string
thankYouMessage?: string
logoUrl?: string
}
authType?: 'public' | 'password' | 'email'
password?: string
allowedEmails?: string[]
showBranding?: boolean
}
interface UpdateFormParams {
identifier?: string
title?: string
description?: string
customizations?: {
primaryColor?: string
welcomeMessage?: string
thankYouTitle?: string
thankYouMessage?: string
logoUrl?: string
}
authType?: 'public' | 'password' | 'email'
password?: string
allowedEmails?: string[]
showBranding?: boolean
isActive?: boolean
}
interface CreateFormResult {
id: string
formUrl: string
}
export function useFormDeployment() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const createForm = useCallback(
async (params: CreateFormParams): Promise<CreateFormResult | null> => {
setIsSubmitting(true)
setError(null)
try {
const response = await fetch('/api/form', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to create form')
}
logger.info('Form created successfully:', { id: data.id })
return {
id: data.id,
formUrl: data.formUrl,
}
} catch (err: any) {
const errorMessage = err.message || 'Failed to create form'
setError(errorMessage)
logger.error('Error creating form:', err)
throw err
} finally {
setIsSubmitting(false)
}
},
[]
)
const updateForm = useCallback(async (formId: string, params: UpdateFormParams) => {
setIsSubmitting(true)
setError(null)
try {
const response = await fetch(`/api/form/manage/${formId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to update form')
}
logger.info('Form updated successfully:', { id: formId })
} catch (err: any) {
const errorMessage = err.message || 'Failed to update form'
setError(errorMessage)
logger.error('Error updating form:', err)
throw err
} finally {
setIsSubmitting(false)
}
}, [])
const deleteForm = useCallback(async (formId: string) => {
setIsSubmitting(true)
setError(null)
try {
const response = await fetch(`/api/form/manage/${formId}`, {
method: 'DELETE',
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to delete form')
}
logger.info('Form deleted successfully:', { id: formId })
} catch (err: any) {
const errorMessage = err.message || 'Failed to delete form'
setError(errorMessage)
logger.error('Error deleting form:', err)
throw err
} finally {
setIsSubmitting(false)
}
}, [])
return {
createForm,
updateForm,
deleteForm,
isSubmitting,
error,
}
}

View File

@@ -15,7 +15,7 @@ import {
import { Skeleton } from '@/components/ui'
import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import { isValidStartBlockType } from '@/lib/workflows/triggers/trigger-utils'
import type { InputFormatField } from '@/lib/workflows/types'
import {
useAddWorkflowMcpTool,
@@ -43,6 +43,7 @@ interface McpDeployProps {
onAddedToServer?: () => void
onSubmittingChange?: (submitting: boolean) => void
onCanSaveChange?: (canSave: boolean) => void
onHasServersChange?: (hasServers: boolean) => void
}
/**
@@ -91,6 +92,7 @@ export function McpDeploy({
onAddedToServer,
onSubmittingChange,
onCanSaveChange,
onHasServersChange,
}: McpDeployProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -255,6 +257,10 @@ export function McpDeploy({
onCanSaveChange?.(hasChanges && hasDeployedTools && !!toolName.trim())
}, [hasChanges, hasDeployedTools, toolName, onCanSaveChange])
useEffect(() => {
onHasServersChange?.(servers.length > 0)
}, [servers.length, onHasServersChange])
/**
* Save tool configuration to all deployed servers
*/

View File

@@ -20,7 +20,6 @@ import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { captureAndUploadOGImage, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from '@/lib/og'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { useCreatorProfiles } from '@/hooks/queries/creator-profile'
import {
useCreateTemplate,
useDeleteTemplate,
@@ -48,11 +47,26 @@ const initialFormData: TemplateFormData = {
tags: [],
}
interface CreatorOption {
id: string
name: string
referenceType: 'user' | 'organization'
referenceId: string
}
interface TemplateStatus {
status: 'pending' | 'approved' | 'rejected' | null
views?: number
stars?: number
}
interface TemplateDeployProps {
workflowId: string
onDeploymentComplete?: () => void
onValidationChange?: (isValid: boolean) => void
onSubmittingChange?: (isSubmitting: boolean) => void
onExistingTemplateChange?: (exists: boolean) => void
onTemplateStatusChange?: (status: TemplateStatus | null) => void
}
export function TemplateDeploy({
@@ -60,9 +74,13 @@ export function TemplateDeploy({
onDeploymentComplete,
onValidationChange,
onSubmittingChange,
onExistingTemplateChange,
onTemplateStatusChange,
}: TemplateDeployProps) {
const { data: session } = useSession()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [creatorOptions, setCreatorOptions] = useState<CreatorOption[]>([])
const [loadingCreators, setLoadingCreators] = useState(false)
const [isCapturing, setIsCapturing] = useState(false)
const previewContainerRef = useRef<HTMLDivElement>(null)
const ogCaptureRef = useRef<HTMLDivElement>(null)
@@ -70,7 +88,6 @@ export function TemplateDeploy({
const [formData, setFormData] = useState<TemplateFormData>(initialFormData)
const { data: existingTemplate, isLoading: isLoadingTemplate } = useTemplateByWorkflow(workflowId)
const { data: creatorProfiles = [], isLoading: loadingCreators } = useCreatorProfiles()
const createMutation = useCreateTemplate()
const updateMutation = useUpdateTemplate()
const deleteMutation = useDeleteTemplate()
@@ -95,15 +112,63 @@ export function TemplateDeploy({
}, [isSubmitting, onSubmittingChange])
useEffect(() => {
if (creatorProfiles.length === 1 && !formData.creatorId) {
updateField('creatorId', creatorProfiles[0].id)
logger.info('Auto-selected single creator profile:', creatorProfiles[0].name)
}
}, [creatorProfiles, formData.creatorId])
onExistingTemplateChange?.(!!existingTemplate)
}, [existingTemplate, onExistingTemplateChange])
useEffect(() => {
const handleCreatorProfileSaved = () => {
logger.info('Creator profile saved, reopening deploy modal...')
if (existingTemplate) {
onTemplateStatusChange?.({
status: existingTemplate.status as 'pending' | 'approved' | 'rejected',
views: existingTemplate.views,
stars: existingTemplate.stars,
})
} else {
onTemplateStatusChange?.(null)
}
}, [existingTemplate, onTemplateStatusChange])
const fetchCreatorOptions = async () => {
if (!session?.user?.id) return
setLoadingCreators(true)
try {
const response = await fetch('/api/creators')
if (response.ok) {
const data = await response.json()
const profiles = (data.profiles || []).map((profile: any) => ({
id: profile.id,
name: profile.name,
referenceType: profile.referenceType,
referenceId: profile.referenceId,
}))
setCreatorOptions(profiles)
return profiles
}
} catch (error) {
logger.error('Error fetching creator profiles:', error)
} finally {
setLoadingCreators(false)
}
return []
}
useEffect(() => {
fetchCreatorOptions()
}, [session?.user?.id])
useEffect(() => {
if (creatorOptions.length === 1 && !formData.creatorId) {
updateField('creatorId', creatorOptions[0].id)
logger.info('Auto-selected single creator profile:', creatorOptions[0].name)
}
}, [creatorOptions, formData.creatorId])
useEffect(() => {
const handleCreatorProfileSaved = async () => {
logger.info('Creator profile saved, refreshing profiles...')
await fetchCreatorOptions()
window.dispatchEvent(new CustomEvent('close-settings'))
setTimeout(() => {
window.dispatchEvent(new CustomEvent('open-deploy-modal', { detail: { tab: 'template' } }))
@@ -292,7 +357,7 @@ export function TemplateDeploy({
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Creator <span className='text-[var(--text-error)]'>*</span>
</Label>
{creatorProfiles.length === 0 && !loadingCreators ? (
{creatorOptions.length === 0 && !loadingCreators ? (
<div className='space-y-[8px]'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
A creator profile is required to publish templates.
@@ -320,9 +385,9 @@ export function TemplateDeploy({
</div>
) : (
<Combobox
options={creatorProfiles.map((profile) => ({
label: profile.name,
value: profile.id,
options={creatorOptions.map((option) => ({
label: option.name,
value: option.id,
}))}
value={formData.creatorId}
selectedValue={formData.creatorId}

View File

@@ -1,8 +1,7 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import {
Badge,
Button,
@@ -18,22 +17,11 @@ import {
} from '@/components/emcn'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/components'
import { startsWithUuid } from '@/executor/constants'
import { useA2AAgentByWorkflow } from '@/hooks/queries/a2a/agents'
import { useApiKeys } from '@/hooks/queries/api-keys'
import {
deploymentKeys,
useActivateDeploymentVersion,
useChatDeploymentInfo,
useDeploymentInfo,
useDeploymentVersions,
useDeployWorkflow,
useUndeployWorkflow,
} from '@/hooks/queries/deployments'
import { useTemplateByWorkflow } from '@/hooks/queries/templates'
import { useWorkflowMcpServers } from '@/hooks/queries/workflow-mcp-servers'
import { useWorkspaceSettings } from '@/hooks/queries/workspace'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsModalStore } from '@/stores/modals/settings/store'
@@ -60,7 +48,7 @@ interface DeployModalProps {
refetchDeployedState: () => Promise<void>
}
interface WorkflowDeploymentInfoUI {
interface WorkflowDeploymentInfo {
isDeployed: boolean
deployedAt?: string
apiKey: string
@@ -81,12 +69,16 @@ export function DeployModal({
isLoadingDeployedState,
refetchDeployedState,
}: DeployModalProps) {
const queryClient = useQueryClient()
const openSettingsModal = useSettingsModalStore((state) => state.openModal)
const deploymentStatus = useWorkflowRegistry((state) =>
state.getWorkflowDeploymentStatus(workflowId)
)
const isDeployed = deploymentStatus?.isDeployed ?? isDeployedProp
const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isUndeploying, setIsUndeploying] = useState(false)
const [deploymentInfo, setDeploymentInfo] = useState<WorkflowDeploymentInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const workflowMetadata = useWorkflowRegistry((state) =>
workflowId ? state.workflows[workflowId] : undefined
)
@@ -94,18 +86,33 @@ export function DeployModal({
const [activeTab, setActiveTab] = useState<TabView>('general')
const [chatSubmitting, setChatSubmitting] = useState(false)
const [apiDeployError, setApiDeployError] = useState<string | null>(null)
const [chatExists, setChatExists] = useState(false)
const [isChatFormValid, setIsChatFormValid] = useState(false)
const [selectedStreamingOutputs, setSelectedStreamingOutputs] = useState<string[]>([])
const [versions, setVersions] = useState<WorkflowDeploymentVersionResponse[]>([])
const [versionsLoading, setVersionsLoading] = useState(false)
const [showUndeployConfirm, setShowUndeployConfirm] = useState(false)
const [templateFormValid, setTemplateFormValid] = useState(false)
const [templateSubmitting, setTemplateSubmitting] = useState(false)
const [mcpToolSubmitting, setMcpToolSubmitting] = useState(false)
const [mcpToolCanSave, setMcpToolCanSave] = useState(false)
const [hasMcpServers, setHasMcpServers] = useState(false)
const [a2aSubmitting, setA2aSubmitting] = useState(false)
const [a2aCanSave, setA2aCanSave] = useState(false)
const [hasA2aAgent, setHasA2aAgent] = useState(false)
const [isA2aPublished, setIsA2aPublished] = useState(false)
const [a2aNeedsRepublish, setA2aNeedsRepublish] = useState(false)
const [showA2aDeleteConfirm, setShowA2aDeleteConfirm] = useState(false)
const [hasExistingTemplate, setHasExistingTemplate] = useState(false)
const [templateStatus, setTemplateStatus] = useState<{
status: 'pending' | 'approved' | 'rejected' | null
views?: number
stars?: number
} | null>(null)
const [existingChat, setExistingChat] = useState<ExistingChat | null>(null)
const [isLoadingChat, setIsLoadingChat] = useState(false)
const [chatSuccess, setChatSuccess] = useState(false)
@@ -126,107 +133,193 @@ export function DeployModal({
const createButtonDisabled =
isApiKeysLoading || (!allowPersonalApiKeys && !canManageWorkspaceKeys)
const {
data: deploymentInfoData,
isLoading: isLoadingDeploymentInfo,
refetch: refetchDeploymentInfo,
} = useDeploymentInfo(workflowId, { enabled: open && isDeployed })
const {
data: versionsData,
isLoading: versionsLoading,
refetch: refetchVersions,
} = useDeploymentVersions(workflowId, { enabled: open })
const {
isLoading: isLoadingChat,
chatExists,
existingChat,
refetch: refetchChatInfo,
} = useChatDeploymentInfo(workflowId, { enabled: open })
const { data: mcpServers = [] } = useWorkflowMcpServers(workflowWorkspaceId || '')
const hasMcpServers = mcpServers.length > 0
const { data: existingA2aAgent } = useA2AAgentByWorkflow(
workflowWorkspaceId || '',
workflowId || ''
)
const hasA2aAgent = !!existingA2aAgent
const isA2aPublished = existingA2aAgent?.isPublished ?? false
const { data: existingTemplate } = useTemplateByWorkflow(workflowId || '', {
enabled: !!workflowId,
})
const hasExistingTemplate = !!existingTemplate
const templateStatus = existingTemplate
? {
status: existingTemplate.status as 'pending' | 'approved' | 'rejected' | null,
views: existingTemplate.views,
stars: existingTemplate.stars,
}
: null
const deployMutation = useDeployWorkflow()
const undeployMutation = useUndeployWorkflow()
const activateVersionMutation = useActivateDeploymentVersion()
const versions = versionsData?.versions ?? []
const getApiKeyLabel = useCallback(
(value?: string | null) => {
if (value && value.trim().length > 0) {
return value
}
return workflowWorkspaceId ? 'Workspace API keys' : 'Personal API keys'
},
[workflowWorkspaceId]
)
const getApiHeaderPlaceholder = useCallback(
() => (workflowWorkspaceId ? 'YOUR_WORKSPACE_API_KEY' : 'YOUR_PERSONAL_API_KEY'),
[workflowWorkspaceId]
)
const getInputFormatExample = useCallback(
(includeStreaming = false) => {
return getInputFormatExampleUtil(includeStreaming, selectedStreamingOutputs)
},
[selectedStreamingOutputs]
)
const deploymentInfo: WorkflowDeploymentInfoUI | null = useMemo(() => {
if (!deploymentInfoData?.isDeployed || !workflowId) {
return null
const getApiKeyLabel = (value?: string | null) => {
if (value && value.trim().length > 0) {
return value
}
return workflowWorkspaceId ? 'Workspace API keys' : 'Personal API keys'
}
const endpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
const placeholderKey = getApiHeaderPlaceholder()
const getApiHeaderPlaceholder = () =>
workflowWorkspaceId ? 'YOUR_WORKSPACE_API_KEY' : 'YOUR_PERSONAL_API_KEY'
return {
isDeployed: deploymentInfoData.isDeployed,
deployedAt: deploymentInfoData.deployedAt ?? undefined,
apiKey: getApiKeyLabel(deploymentInfoData.apiKey),
endpoint,
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`,
needsRedeployment: deploymentInfoData.needsRedeployment,
const getInputFormatExample = (includeStreaming = false) => {
return getInputFormatExampleUtil(includeStreaming, selectedStreamingOutputs)
}
const fetchChatDeploymentInfo = useCallback(async () => {
if (!workflowId) return
try {
setIsLoadingChat(true)
const response = await fetch(`/api/workflows/${workflowId}/chat/status`)
if (response.ok) {
const data = await response.json()
if (data.isDeployed && data.deployment) {
const detailResponse = await fetch(`/api/chat/manage/${data.deployment.id}`)
if (detailResponse.ok) {
const chatDetail = await detailResponse.json()
setExistingChat(chatDetail)
setChatExists(true)
} else {
setExistingChat(null)
setChatExists(false)
}
} else {
setExistingChat(null)
setChatExists(false)
}
} else {
setExistingChat(null)
setChatExists(false)
}
} catch (error) {
logger.error('Error fetching chat deployment info:', { error })
setExistingChat(null)
setChatExists(false)
} finally {
setIsLoadingChat(false)
}
}, [
deploymentInfoData,
workflowId,
selectedStreamingOutputs,
getInputFormatExample,
getApiHeaderPlaceholder,
getApiKeyLabel,
])
}, [workflowId])
useEffect(() => {
if (open && workflowId) {
setActiveTab('general')
setApiDeployError(null)
fetchChatDeploymentInfo()
}
}, [open, workflowId])
}, [open, workflowId, fetchChatDeploymentInfo])
useEffect(() => {
async function fetchDeploymentInfo() {
if (!open || !workflowId || !isDeployed) {
setDeploymentInfo(null)
setIsLoading(false)
return
}
if (deploymentInfo?.isDeployed && !needsRedeployment) {
setIsLoading(false)
return
}
try {
setIsLoading(true)
const response = await fetch(`/api/workflows/${workflowId}/deploy`)
if (!response.ok) {
throw new Error('Failed to fetch deployment information')
}
const data = await response.json()
const endpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
const placeholderKey = workflowWorkspaceId ? 'YOUR_WORKSPACE_API_KEY' : 'YOUR_API_KEY'
setDeploymentInfo({
isDeployed: data.isDeployed,
deployedAt: data.deployedAt,
apiKey: data.apiKey || placeholderKey,
endpoint,
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`,
needsRedeployment,
})
} catch (error) {
logger.error('Error fetching deployment info:', { error })
} finally {
setIsLoading(false)
}
}
fetchDeploymentInfo()
}, [open, workflowId, isDeployed, needsRedeployment, deploymentInfo?.isDeployed])
const onDeploy = async () => {
setApiDeployError(null)
try {
setIsSubmitting(true)
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
deployChatEnabled: false,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to deploy workflow')
}
const responseData = await response.json()
const isDeployedStatus = responseData.isDeployed ?? false
const deployedAtTime = responseData.deployedAt ? new Date(responseData.deployedAt) : undefined
const apiKeyLabel = getApiKeyLabel(responseData.apiKey)
setDeploymentStatus(workflowId, isDeployedStatus, deployedAtTime, apiKeyLabel)
if (workflowId) {
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
}
await refetchDeployedState()
await fetchVersions()
const deploymentInfoResponse = await fetch(`/api/workflows/${workflowId}/deploy`)
if (deploymentInfoResponse.ok) {
const deploymentData = await deploymentInfoResponse.json()
const apiEndpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
const placeholderKey = getApiHeaderPlaceholder()
setDeploymentInfo({
isDeployed: deploymentData.isDeployed,
deployedAt: deploymentData.deployedAt,
apiKey: getApiKeyLabel(deploymentData.apiKey),
endpoint: apiEndpoint,
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`,
needsRedeployment: false,
})
}
setApiDeployError(null)
} catch (error: unknown) {
logger.error('Error deploying workflow:', { error })
const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow'
setApiDeployError(errorMessage)
} finally {
setIsSubmitting(false)
}
}
const fetchVersions = useCallback(async () => {
if (!workflowId) return
try {
const res = await fetch(`/api/workflows/${workflowId}/deployments`)
if (res.ok) {
const data = await res.json()
setVersions(Array.isArray(data.versions) ? data.versions : [])
} else {
setVersions([])
}
} catch {
setVersions([])
}
}, [workflowId])
useEffect(() => {
if (open && workflowId) {
setVersionsLoading(true)
fetchVersions().finally(() => setVersionsLoading(false))
}
}, [open, workflowId, fetchVersions])
useEffect(() => {
if (!open || selectedStreamingOutputs.length === 0) return
@@ -276,88 +369,181 @@ export function DeployModal({
}
}, [onOpenChange])
const onDeploy = useCallback(async () => {
if (!workflowId) return
setApiDeployError(null)
try {
await deployMutation.mutateAsync({ workflowId, deployChatEnabled: false })
await refetchDeployedState()
} catch (error: unknown) {
logger.error('Error deploying workflow:', { error })
const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow'
setApiDeployError(errorMessage)
}
}, [workflowId, deployMutation, refetchDeployedState])
const handlePromoteToLive = useCallback(
async (version: number) => {
if (!workflowId) return
const previousVersions = [...versions]
setVersions((prev) =>
prev.map((v) => ({
...v,
isActive: v.version === version,
}))
)
try {
await activateVersionMutation.mutateAsync({ workflowId, version })
await refetchDeployedState()
const response = await fetch(
`/api/workflows/${workflowId}/deployments/${version}/activate`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
}
)
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to promote version')
}
const responseData = await response.json()
const deployedAtTime = responseData.deployedAt
? new Date(responseData.deployedAt)
: undefined
const apiKeyLabel = getApiKeyLabel(responseData.apiKey)
setDeploymentStatus(workflowId, true, deployedAtTime, apiKeyLabel)
refetchDeployedState()
fetchVersions()
const deploymentInfoResponse = await fetch(`/api/workflows/${workflowId}/deploy`)
if (deploymentInfoResponse.ok) {
const deploymentData = await deploymentInfoResponse.json()
const apiEndpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
const placeholderKey = getApiHeaderPlaceholder()
setDeploymentInfo({
isDeployed: deploymentData.isDeployed,
deployedAt: deploymentData.deployedAt,
apiKey: getApiKeyLabel(deploymentData.apiKey),
endpoint: apiEndpoint,
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`,
needsRedeployment: false,
})
}
} catch (error) {
logger.error('Error promoting version:', { error })
setVersions(previousVersions)
throw error
}
},
[workflowId, activateVersionMutation, refetchDeployedState]
[workflowId, versions, refetchDeployedState, fetchVersions, selectedStreamingOutputs]
)
const handleUndeploy = useCallback(async () => {
if (!workflowId) return
const handleUndeploy = async () => {
try {
await undeployMutation.mutateAsync({ workflowId })
setIsUndeploying(true)
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
method: 'DELETE',
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to undeploy workflow')
}
setDeploymentStatus(workflowId, false)
setChatExists(false)
setShowUndeployConfirm(false)
onOpenChange(false)
} catch (error: unknown) {
logger.error('Error undeploying workflow:', { error })
} finally {
setIsUndeploying(false)
}
}, [workflowId, undeployMutation, onOpenChange])
const handleRedeploy = useCallback(async () => {
if (!workflowId) return
setApiDeployError(null)
}
const handleRedeploy = async () => {
try {
await deployMutation.mutateAsync({ workflowId, deployChatEnabled: false })
setIsSubmitting(true)
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
deployChatEnabled: false,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to redeploy workflow')
}
const { isDeployed: newDeployStatus, deployedAt, apiKey } = await response.json()
setDeploymentStatus(
workflowId,
newDeployStatus,
deployedAt ? new Date(deployedAt) : undefined,
getApiKeyLabel(apiKey)
)
if (workflowId) {
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
}
await refetchDeployedState()
await fetchVersions()
setDeploymentInfo((prev) => (prev ? { ...prev, needsRedeployment: false } : prev))
} catch (error: unknown) {
logger.error('Error redeploying workflow:', { error })
const errorMessage = error instanceof Error ? error.message : 'Failed to redeploy workflow'
setApiDeployError(errorMessage)
} finally {
setIsSubmitting(false)
}
}, [workflowId, deployMutation, refetchDeployedState])
}
const handleCloseModal = useCallback(() => {
const handleCloseModal = () => {
setIsSubmitting(false)
setChatSubmitting(false)
setApiDeployError(null)
onOpenChange(false)
}, [onOpenChange])
const handleChatDeployed = useCallback(async () => {
if (!workflowId) return
queryClient.invalidateQueries({ queryKey: deploymentKeys.info(workflowId) })
queryClient.invalidateQueries({ queryKey: deploymentKeys.versions(workflowId) })
queryClient.invalidateQueries({ queryKey: deploymentKeys.chatStatus(workflowId) })
await refetchDeployedState()
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
}
const handleChatDeployed = async () => {
await handlePostDeploymentUpdate()
setChatSuccess(true)
setTimeout(() => setChatSuccess(false), 2000)
}, [workflowId, queryClient, refetchDeployedState])
}
const handleRefetchChat = useCallback(async () => {
await refetchChatInfo()
}, [refetchChatInfo])
const handlePostDeploymentUpdate = async () => {
if (!workflowId) return
const handleChatFormSubmit = useCallback(() => {
setDeploymentStatus(workflowId, true, new Date(), getApiKeyLabel())
const deploymentInfoResponse = await fetch(`/api/workflows/${workflowId}/deploy`)
if (deploymentInfoResponse.ok) {
const deploymentData = await deploymentInfoResponse.json()
const apiEndpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
const placeholderKey = getApiHeaderPlaceholder()
setDeploymentInfo({
isDeployed: deploymentData.isDeployed,
deployedAt: deploymentData.deployedAt,
apiKey: getApiKeyLabel(deploymentData.apiKey),
endpoint: apiEndpoint,
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`,
needsRedeployment: false,
})
}
await refetchDeployedState()
await fetchVersions()
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
}
const handleChatFormSubmit = () => {
const form = document.getElementById('chat-deploy-form') as HTMLFormElement
if (form) {
const updateTrigger = form.querySelector('[data-update-trigger]') as HTMLButtonElement
@@ -367,9 +553,9 @@ export function DeployModal({
form.requestSubmit()
}
}
}, [])
}
const handleChatDelete = useCallback(() => {
const handleChatDelete = () => {
const form = document.getElementById('chat-deploy-form') as HTMLFormElement
if (form) {
const deleteButton = form.querySelector('[data-delete-trigger]') as HTMLButtonElement
@@ -377,7 +563,7 @@ export function DeployModal({
deleteButton.click()
}
}
}, [])
}
const handleTemplateFormSubmit = useCallback(() => {
const form = document.getElementById('template-deploy-form') as HTMLFormElement
@@ -437,13 +623,6 @@ export function DeployModal({
deleteTrigger?.click()
}, [])
const handleFetchVersions = useCallback(async () => {
await refetchVersions()
}, [refetchVersions])
const isSubmitting = deployMutation.isPending
const isUndeploying = undeployMutation.isPending
return (
<>
<Modal open={open} onOpenChange={handleCloseModal}>
@@ -491,7 +670,7 @@ export function DeployModal({
versionsLoading={versionsLoading}
onPromoteToLive={handlePromoteToLive}
onLoadDeploymentComplete={handleCloseModal}
fetchVersions={handleFetchVersions}
fetchVersions={fetchVersions}
/>
</ModalTabsContent>
@@ -499,7 +678,7 @@ export function DeployModal({
<ApiDeploy
workflowId={workflowId}
deploymentInfo={deploymentInfo}
isLoading={isLoadingDeploymentInfo}
isLoading={isLoading}
needsRedeployment={needsRedeployment}
apiDeployError={apiDeployError}
getInputFormatExample={getInputFormatExample}
@@ -512,9 +691,10 @@ export function DeployModal({
<ChatDeploy
workflowId={workflowId || ''}
deploymentInfo={deploymentInfo}
existingChat={existingChat as ExistingChat | null}
existingChat={existingChat}
isLoadingChat={isLoadingChat}
onRefetchChat={handleRefetchChat}
onRefetchChat={fetchChatDeploymentInfo}
onChatExistsChange={setChatExists}
chatSubmitting={chatSubmitting}
setChatSubmitting={setChatSubmitting}
onValidationChange={setIsChatFormValid}
@@ -531,6 +711,8 @@ export function DeployModal({
onDeploymentComplete={handleCloseModal}
onValidationChange={setTemplateFormValid}
onSubmittingChange={setTemplateSubmitting}
onExistingTemplateChange={setHasExistingTemplate}
onTemplateStatusChange={setTemplateStatus}
/>
)}
</ModalTabsContent>
@@ -559,6 +741,7 @@ export function DeployModal({
isDeployed={isDeployed}
onSubmittingChange={setMcpToolSubmitting}
onCanSaveChange={setMcpToolCanSave}
onHasServersChange={setHasMcpServers}
/>
)}
</ModalTabsContent>
@@ -573,6 +756,8 @@ export function DeployModal({
workflowNeedsRedeployment={needsRedeployment}
onSubmittingChange={setA2aSubmitting}
onCanSaveChange={setA2aCanSave}
onAgentExistsChange={setHasA2aAgent}
onPublishedChange={setIsA2aPublished}
onNeedsRepublishChange={setA2aNeedsRepublish}
onDeployWorkflow={onDeploy}
/>
@@ -658,7 +843,7 @@ export function DeployModal({
onClick={handleMcpToolFormSubmit}
disabled={mcpToolSubmitting || !mcpToolCanSave}
>
{mcpToolSubmitting ? 'Saving...' : 'Save Tool'}
{mcpToolSubmitting ? 'Saving...' : 'Save Tool Schema'}
</Button>
</div>
</ModalFooter>

View File

@@ -2,19 +2,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useReactFlow } from 'reactflow'
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
import { cn } from '@/lib/core/utils/cn'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sub-block-input-controller'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { getDependsOnFields } from '@/blocks/utils'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { getProviderFromModel } from '@/providers/utils'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
/**
* Constants for ComboBox component behavior
@@ -94,24 +91,15 @@ export function ComboBox({
// Dependency tracking for fetchOptions
const dependsOnFields = useMemo(() => getDependsOnFields(dependsOn), [dependsOn])
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const blockState = useWorkflowStore((state) => state.blocks[blockId])
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
const canonicalIndex = useMemo(
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
[blockConfig?.subBlocks]
)
const canonicalModeOverrides = blockState?.data?.canonicalModes
const dependencyValues = useSubBlockStore(
useCallback(
(state) => {
if (dependsOnFields.length === 0 || !activeWorkflowId) return []
const workflowValues = state.workflowValues[activeWorkflowId] || {}
const blockValues = workflowValues[blockId] || {}
return dependsOnFields.map((depKey) =>
resolveDependencyValue(depKey, blockValues, canonicalIndex, canonicalModeOverrides)
)
return dependsOnFields.map((depKey) => blockValues[depKey] ?? null)
},
[dependsOnFields, activeWorkflowId, blockId, canonicalIndex, canonicalModeOverrides]
[dependsOnFields, activeWorkflowId, blockId]
)
)

View File

@@ -1,6 +1,6 @@
'use client'
import { useMemo, useState } from 'react'
import { useMemo } from 'react'
import { createLogger } from '@sim/logger'
import { Check } from 'lucide-react'
import {
@@ -308,7 +308,6 @@ export function OAuthRequiredModal({
serviceId,
newScopes = [],
}: OAuthRequiredModalProps) {
const [error, setError] = useState<string | null>(null)
const { baseProvider } = parseProvider(provider)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
@@ -349,24 +348,23 @@ export function OAuthRequiredModal({
}, [requiredScopes, newScopesSet])
const handleConnectDirectly = async () => {
setError(null)
try {
const providerId = getProviderIdFromServiceId(serviceId)
onClose()
logger.info('Linking OAuth2:', {
providerId,
requiredScopes,
})
if (providerId === 'trello') {
onClose()
window.location.href = '/api/auth/trello/authorize'
return
}
if (providerId === 'shopify') {
onClose()
// Pass the current URL so we can redirect back after OAuth
const returnUrl = encodeURIComponent(window.location.href)
window.location.href = `/api/auth/shopify/authorize?returnUrl=${returnUrl}`
return
@@ -376,10 +374,8 @@ export function OAuthRequiredModal({
providerId,
callbackURL: window.location.href,
})
onClose()
} catch (err) {
logger.error('Error initiating OAuth flow:', { error: err })
setError('Failed to connect. Please try again.')
} catch (error) {
logger.error('Error initiating OAuth flow:', { error })
}
}
@@ -429,12 +425,10 @@ export function OAuthRequiredModal({
</ul>
</div>
)}
{error && <p className='text-[12px] text-[var(--text-error)]'>{error}</p>}
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={onClose}>
<Button variant='active' onClick={onClose}>
Cancel
</Button>
<Button variant='tertiary' type='button' onClick={handleConnectDirectly}>

View File

@@ -1,15 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Badge } from '@/components/emcn'
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { getDependsOnFields } from '@/blocks/utils'
import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
/**
* Dropdown option type - can be a simple string or an object with label, id, and optional icon
@@ -92,24 +89,15 @@ export function Dropdown({
const dependsOnFields = useMemo(() => getDependsOnFields(dependsOn), [dependsOn])
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const blockState = useWorkflowStore((state) => state.blocks[blockId])
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
const canonicalIndex = useMemo(
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
[blockConfig?.subBlocks]
)
const canonicalModeOverrides = blockState?.data?.canonicalModes
const dependencyValues = useSubBlockStore(
useCallback(
(state) => {
if (dependsOnFields.length === 0 || !activeWorkflowId) return []
const workflowValues = state.workflowValues[activeWorkflowId] || {}
const blockValues = workflowValues[blockId] || {}
return dependsOnFields.map((depKey) =>
resolveDependencyValue(depKey, blockValues, canonicalIndex, canonicalModeOverrides)
)
return dependsOnFields.map((depKey) => blockValues[depKey] ?? null)
},
[dependsOnFields, activeWorkflowId, blockId, canonicalIndex, canonicalModeOverrides]
[dependsOnFields, activeWorkflowId, blockId]
)
)

View File

@@ -4,19 +4,15 @@ import { useMemo } from 'react'
import { useParams } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import { getProviderIdFromServiceId } from '@/lib/oauth'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { isDependency } from '@/blocks/utils'
import { resolveSelectorForSubBlock, type SelectorResolution } from '@/hooks/selectors/resolution'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
interface FileSelectorInputProps {
blockId: string
@@ -46,59 +42,21 @@ export function FileSelectorInput({
previewContextValues,
})
const blockState = useWorkflowStore((state) => state.blocks[blockId])
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
const canonicalIndex = useMemo(
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
[blockConfig?.subBlocks]
)
const canonicalModeOverrides = blockState?.data?.canonicalModes
const blockValues = useSubBlockStore((state) => {
if (!activeWorkflowId) return {}
const workflowValues = state.workflowValues[activeWorkflowId] || {}
return (workflowValues as Record<string, Record<string, unknown>>)[blockId] || {}
})
const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential')
const [domainValueFromStore] = useSubBlockValue(blockId, 'domain')
const [projectIdValueFromStore] = useSubBlockValue(blockId, 'projectId')
const [planIdValueFromStore] = useSubBlockValue(blockId, 'planId')
const [teamIdValueFromStore] = useSubBlockValue(blockId, 'teamId')
const [siteIdValueFromStore] = useSubBlockValue(blockId, 'siteId')
const [collectionIdValueFromStore] = useSubBlockValue(blockId, 'collectionId')
const connectedCredential = previewContextValues?.credential ?? blockValues.credential
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
const domainValue = previewContextValues?.domain ?? domainValueFromStore
const teamIdValue = useMemo(
() =>
previewContextValues?.teamId ??
resolveDependencyValue('teamId', blockValues, canonicalIndex, canonicalModeOverrides),
[previewContextValues?.teamId, blockValues, canonicalIndex, canonicalModeOverrides]
)
const siteIdValue = useMemo(
() =>
previewContextValues?.siteId ??
resolveDependencyValue('siteId', blockValues, canonicalIndex, canonicalModeOverrides),
[previewContextValues?.siteId, blockValues, canonicalIndex, canonicalModeOverrides]
)
const collectionIdValue = useMemo(
() =>
previewContextValues?.collectionId ??
resolveDependencyValue('collectionId', blockValues, canonicalIndex, canonicalModeOverrides),
[previewContextValues?.collectionId, blockValues, canonicalIndex, canonicalModeOverrides]
)
const projectIdValue = useMemo(
() =>
previewContextValues?.projectId ??
resolveDependencyValue('projectId', blockValues, canonicalIndex, canonicalModeOverrides),
[previewContextValues?.projectId, blockValues, canonicalIndex, canonicalModeOverrides]
)
const planIdValue = useMemo(
() =>
previewContextValues?.planId ??
resolveDependencyValue('planId', blockValues, canonicalIndex, canonicalModeOverrides),
[previewContextValues?.planId, blockValues, canonicalIndex, canonicalModeOverrides]
)
const projectIdValue = previewContextValues?.projectId ?? projectIdValueFromStore
const planIdValue = previewContextValues?.planId ?? planIdValueFromStore
const teamIdValue = previewContextValues?.teamId ?? teamIdValueFromStore
const siteIdValue = previewContextValues?.siteId ?? siteIdValueFromStore
const collectionIdValue = previewContextValues?.collectionId ?? collectionIdValueFromStore
const normalizedCredentialId =
typeof connectedCredential === 'string'
@@ -107,6 +65,7 @@ export function FileSelectorInput({
? ((connectedCredential as Record<string, any>).id ?? '')
: ''
// Derive provider from serviceId using OAuth config (same pattern as credential-selector)
const serviceId = subBlock.serviceId || ''
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])

View File

@@ -4,17 +4,14 @@ import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import { getProviderIdFromServiceId } from '@/lib/oauth'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
interface ProjectSelectorInputProps {
blockId: string
@@ -35,36 +32,21 @@ export function ProjectSelectorInput({
previewValue,
previewContextValues,
}: ProjectSelectorInputProps) {
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const params = useParams()
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) as string | null
const [selectedProjectId, setSelectedProjectId] = useState<string>('')
// Use the proper hook to get the current value and setter
const [storeValue] = useSubBlockValue(blockId, subBlock.id)
const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential')
const [linearTeamIdFromStore] = useSubBlockValue(blockId, 'teamId')
const [jiraDomainFromStore] = useSubBlockValue(blockId, 'domain')
const blockState = useWorkflowStore((state) => state.blocks[blockId])
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
const canonicalIndex = useMemo(
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
[blockConfig?.subBlocks]
)
const canonicalModeOverrides = blockState?.data?.canonicalModes
const blockValues = useSubBlockStore((state) => {
if (!activeWorkflowId) return {}
const workflowValues = state.workflowValues[activeWorkflowId] || {}
return (workflowValues as Record<string, Record<string, unknown>>)[blockId] || {}
})
const connectedCredential = previewContextValues?.credential ?? blockValues.credential
// Use previewContextValues if provided (for tools inside agent blocks), otherwise use store values
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
const linearTeamId = previewContextValues?.teamId ?? linearTeamIdFromStore
const jiraDomain = previewContextValues?.domain ?? jiraDomainFromStore
const linearTeamId = useMemo(
() =>
previewContextValues?.teamId ??
resolveDependencyValue('teamId', blockValues, canonicalIndex, canonicalModeOverrides),
[previewContextValues?.teamId, blockValues, canonicalIndex, canonicalModeOverrides]
)
// Derive provider from serviceId using OAuth config
const serviceId = subBlock.serviceId || ''
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
@@ -72,6 +54,7 @@ export function ProjectSelectorInput({
effectiveProviderId,
(connectedCredential as string) || ''
)
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) as string | null
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
disabled,
@@ -79,8 +62,12 @@ export function ProjectSelectorInput({
previewContextValues,
})
// Jira/Discord upstream fields - use values from previewContextValues or store
const domain = (jiraDomain as string) || ''
// Verify Jira credential belongs to current user; if not, treat as absent
// Get the current value from the store or prop value if in preview mode
useEffect(() => {
if (isPreview && previewValue !== undefined) {
setSelectedProjectId(previewValue)

View File

@@ -4,17 +4,14 @@ import { useMemo } from 'react'
import { useParams } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import { getProviderIdFromServiceId } from '@/lib/oauth'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { getBlock } from '@/blocks/registry'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { resolveSelectorForSubBlock, type SelectorResolution } from '@/hooks/selectors/resolution'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
interface SheetSelectorInputProps {
blockId: string
@@ -44,32 +41,16 @@ export function SheetSelectorInput({
previewContextValues,
})
const blockState = useWorkflowStore((state) => state.blocks[blockId])
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
const canonicalIndex = useMemo(
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
[blockConfig?.subBlocks]
)
const canonicalModeOverrides = blockState?.data?.canonicalModes
const blockValues = useSubBlockStore((state) => {
if (!activeWorkflowId) return {}
const workflowValues = state.workflowValues[activeWorkflowId] || {}
return (workflowValues as Record<string, Record<string, unknown>>)[blockId] || {}
})
const connectedCredentialFromStore = blockValues.credential
const spreadsheetIdFromStore = useMemo(
() =>
resolveDependencyValue('spreadsheetId', blockValues, canonicalIndex, canonicalModeOverrides),
[blockValues, canonicalIndex, canonicalModeOverrides]
)
const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential')
const [spreadsheetIdFromStore] = useSubBlockValue(blockId, 'spreadsheetId')
const [manualSpreadsheetIdFromStore] = useSubBlockValue(blockId, 'manualSpreadsheetId')
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
const spreadsheetId = previewContextValues
? (previewContextValues.spreadsheetId ?? previewContextValues.manualSpreadsheetId)
: spreadsheetIdFromStore
const spreadsheetId =
previewContextValues?.spreadsheetId ??
spreadsheetIdFromStore ??
previewContextValues?.manualSpreadsheetId ??
manualSpreadsheetIdFromStore
const normalizedCredentialId =
typeof connectedCredential === 'string'
@@ -80,6 +61,7 @@ export function SheetSelectorInput({
const normalizedSpreadsheetId = typeof spreadsheetId === 'string' ? spreadsheetId.trim() : ''
// Derive provider from serviceId using OAuth config
const serviceId = subBlock.serviceId || ''
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])

View File

@@ -1,11 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { AlertCircle, ArrowUp } from 'lucide-react'
import { AlertCircle, Wand2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Badge,
Button,
Input,
Modal,
ModalBody,
ModalContent,
@@ -879,53 +878,35 @@ try {
JSON Schema
</Label>
{schemaError && (
<div className='ml-2 flex min-w-0 items-center gap-1 text-[12px] text-[var(--text-error)]'>
<div className='ml-2 flex min-w-0 items-center gap-1 text-[var(--text-error)] text-xs'>
<AlertCircle className='h-3 w-3 flex-shrink-0' />
<span className='truncate'>{schemaError}</span>
</div>
)}
</div>
<div className='flex min-w-0 items-center justify-end gap-[4px]'>
<div className='flex min-w-0 flex-1 items-center justify-end gap-1 pr-[4px]'>
{!isSchemaPromptActive ? (
<Button
variant='active'
className='-my-1 h-5 px-2 py-0 text-[11px]'
<button
type='button'
onClick={handleSchemaWandClick}
disabled={schemaGeneration.isLoading || schemaGeneration.isStreaming}
className='inline-flex h-[16px] w-[16px] items-center justify-center rounded-full hover:bg-transparent disabled:opacity-50'
aria-label='Generate schema with AI'
>
Generate
</Button>
<Wand2 className='!h-[12px] !w-[12px] text-[var(--text-secondary)]' />
</button>
) : (
<div className='-my-1 flex items-center gap-[4px]'>
<Input
ref={schemaPromptInputRef}
value={schemaGeneration.isStreaming ? 'Generating...' : schemaPromptInput}
onChange={(e) => handleSchemaPromptChange(e.target.value)}
onBlur={handleSchemaPromptBlur}
onKeyDown={handleSchemaPromptKeyDown}
disabled={schemaGeneration.isStreaming}
className={cn(
'h-5 max-w-[200px] flex-1 text-[11px]',
schemaGeneration.isStreaming && 'text-muted-foreground'
)}
placeholder='Generate...'
/>
<Button
variant='tertiary'
disabled={!schemaPromptInput.trim() || schemaGeneration.isStreaming}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={(e) => {
e.stopPropagation()
handleSchemaPromptSubmit()
}}
className='h-[20px] w-[20px] flex-shrink-0 p-0'
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
</div>
<input
ref={schemaPromptInputRef}
type='text'
value={schemaGeneration.isStreaming ? 'Generating...' : schemaPromptInput}
onChange={(e) => handleSchemaPromptChange(e.target.value)}
onBlur={handleSchemaPromptBlur}
onKeyDown={handleSchemaPromptKeyDown}
disabled={schemaGeneration.isStreaming}
className='h-[16px] w-full border-none bg-transparent py-0 pr-[2px] text-right font-medium text-[12px] text-[var(--text-primary)] leading-[14px] placeholder:text-[var(--text-muted)] focus:outline-none'
placeholder='Describe schema...'
/>
)}
</div>
</div>
@@ -971,53 +952,35 @@ try {
Code
</Label>
{codeError && !codeGeneration.isStreaming && (
<div className='ml-2 flex min-w-0 items-center gap-1 text-[12px] text-[var(--text-error)]'>
<div className='ml-2 flex min-w-0 items-center gap-1 text-[var(--text-error)] text-xs'>
<AlertCircle className='h-3 w-3 flex-shrink-0' />
<span className='truncate'>{codeError}</span>
</div>
)}
</div>
<div className='flex min-w-0 items-center justify-end gap-[4px]'>
<div className='flex min-w-0 flex-1 items-center justify-end gap-1 pr-[4px]'>
{!isCodePromptActive ? (
<Button
variant='active'
className='-my-1 h-5 px-2 py-0 text-[11px]'
<button
type='button'
onClick={handleCodeWandClick}
disabled={codeGeneration.isLoading || codeGeneration.isStreaming}
className='inline-flex h-[16px] w-[16px] items-center justify-center rounded-full hover:bg-transparent disabled:opacity-50'
aria-label='Generate code with AI'
>
Generate
</Button>
<Wand2 className='!h-[12px] !w-[12px] text-[var(--text-secondary)]' />
</button>
) : (
<div className='-my-1 flex items-center gap-[4px]'>
<Input
ref={codePromptInputRef}
value={codeGeneration.isStreaming ? 'Generating...' : codePromptInput}
onChange={(e) => handleCodePromptChange(e.target.value)}
onBlur={handleCodePromptBlur}
onKeyDown={handleCodePromptKeyDown}
disabled={codeGeneration.isStreaming}
className={cn(
'h-5 max-w-[200px] flex-1 text-[11px]',
codeGeneration.isStreaming && 'text-muted-foreground'
)}
placeholder='Generate...'
/>
<Button
variant='tertiary'
disabled={!codePromptInput.trim() || codeGeneration.isStreaming}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={(e) => {
e.stopPropagation()
handleCodePromptSubmit()
}}
className='h-[20px] w-[20px] flex-shrink-0 p-0'
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
</div>
<input
ref={codePromptInputRef}
type='text'
value={codeGeneration.isStreaming ? 'Generating...' : codePromptInput}
onChange={(e) => handleCodePromptChange(e.target.value)}
onBlur={handleCodePromptBlur}
onKeyDown={handleCodePromptKeyDown}
disabled={codeGeneration.isStreaming}
className='h-[16px] w-full border-none bg-transparent py-0 pr-[2px] text-right font-medium text-[12px] text-[var(--text-primary)] leading-[14px] placeholder:text-[var(--text-muted)] focus:outline-none'
placeholder='Describe code...'
/>
)}
</div>
</div>

View File

@@ -557,7 +557,7 @@ function FileUploadSyncWrapper({
)
}
function SlackSelectorSyncWrapper({
function ChannelSelectorSyncWrapper({
blockId,
paramId,
value,
@@ -565,7 +565,6 @@ function SlackSelectorSyncWrapper({
uiComponent,
disabled,
previewContextValues,
selectorType,
}: {
blockId: string
paramId: string
@@ -574,7 +573,6 @@ function SlackSelectorSyncWrapper({
uiComponent: any
disabled: boolean
previewContextValues?: Record<string, any>
selectorType: 'channel-selector' | 'user-selector'
}) {
return (
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
@@ -582,7 +580,7 @@ function SlackSelectorSyncWrapper({
blockId={blockId}
subBlock={{
id: paramId,
type: selectorType,
type: 'channel-selector' as const,
title: paramId,
serviceId: uiComponent.serviceId,
placeholder: uiComponent.placeholder,
@@ -1954,7 +1952,7 @@ export function ToolInput({
case 'channel-selector':
return (
<SlackSelectorSyncWrapper
<ChannelSelectorSyncWrapper
blockId={blockId}
paramId={param.id}
value={value}
@@ -1962,21 +1960,6 @@ export function ToolInput({
uiComponent={uiComponent}
disabled={disabled}
previewContextValues={currentToolParams as any}
selectorType='channel-selector'
/>
)
case 'user-selector':
return (
<SlackSelectorSyncWrapper
blockId={blockId}
paramId={param.id}
value={value}
onChange={onChange}
uiComponent={uiComponent}
disabled={disabled}
previewContextValues={currentToolParams as any}
selectorType='user-selector'
/>
)

View File

@@ -1,16 +1,9 @@
'use client'
import { useMemo } from 'react'
import {
buildCanonicalIndex,
isNonEmptyValue,
resolveDependencyValue,
} from '@/lib/workflows/subblocks/visibility'
import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
type DependsOnConfig = string[] | { all?: string[]; any?: string[] }
@@ -57,13 +50,6 @@ export function useDependsOnGate(
const previewContextValues = opts?.previewContextValues
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const blockState = useWorkflowStore((state) => state.blocks[blockId])
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
const canonicalIndex = useMemo(
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
[blockConfig?.subBlocks]
)
const canonicalModeOverrides = blockState?.data?.canonicalModes
// Parse dependsOn config to get all/any field lists
const { allFields, anyFields, allDependsOnFields } = useMemo(
@@ -105,13 +91,7 @@ export function useDependsOnGate(
if (previewContextValues) {
const map: Record<string, unknown> = {}
for (const key of allDependsOnFields) {
const resolvedValue = resolveDependencyValue(
key,
previewContextValues,
canonicalIndex,
canonicalModeOverrides
)
map[key] = normalizeDependencyValue(resolvedValue)
map[key] = normalizeDependencyValue(previewContextValues[key])
}
return map
}
@@ -128,25 +108,32 @@ export function useDependsOnGate(
const blockValues = (workflowValues as any)[blockId] || {}
const map: Record<string, unknown> = {}
for (const key of allDependsOnFields) {
const resolvedValue = resolveDependencyValue(
key,
blockValues,
canonicalIndex,
canonicalModeOverrides
)
map[key] = normalizeDependencyValue(resolvedValue)
map[key] = normalizeDependencyValue((blockValues as any)[key])
}
return map
})
// For backward compatibility, also provide array of values
const dependencyValues = useMemo(
() => allDependsOnFields.map((key) => dependencyValuesMap[key]),
[allDependsOnFields, dependencyValuesMap]
) as any[]
const isValueSatisfied = (value: unknown): boolean => {
if (value === null || value === undefined) return false
if (typeof value === 'string') return value.trim().length > 0
if (Array.isArray(value)) return value.length > 0
return value !== ''
}
const depsSatisfied = useMemo(() => {
// Check all fields (AND logic) - all must be satisfied
const allSatisfied =
allFields.length === 0 || allFields.every((key) => isNonEmptyValue(dependencyValuesMap[key]))
allFields.length === 0 || allFields.every((key) => isValueSatisfied(dependencyValuesMap[key]))
// Check any fields (OR logic) - at least one must be satisfied
const anySatisfied =
anyFields.length === 0 || anyFields.some((key) => isNonEmptyValue(dependencyValuesMap[key]))
anyFields.length === 0 || anyFields.some((key) => isValueSatisfied(dependencyValuesMap[key]))
return allSatisfied && anySatisfied
}, [allFields, anyFields, dependencyValuesMap])
@@ -159,6 +146,7 @@ export function useDependsOnGate(
return {
dependsOn,
dependencyValues,
depsSatisfied,
blocked,
finalDisabled,

View File

@@ -210,11 +210,9 @@ export function useSubBlockValue<T = any>(
)
// Determine the effective value: diff value takes precedence if in diff mode
const effectiveValue = hasSnapshotValue
? snapshotValue
: storeValue !== undefined
? storeValue
: initialValue
// Use nullish coalescing to fall back to initialValue when storeValue is null or undefined
// This handles the case where a block is newly added and the subblock store hasn't been populated yet
const effectiveValue = hasSnapshotValue ? snapshotValue : (storeValue ?? initialValue)
// Initialize valueRef on first render
useEffect(() => {

View File

@@ -1,5 +1,5 @@
import { type JSX, type MouseEvent, memo, useRef, useState } from 'react'
import { AlertTriangle, ArrowLeftRight, ArrowUp } from 'lucide-react'
import { AlertTriangle, ArrowUp } from 'lucide-react'
import { Button, Input, Label, Tooltip } from '@/components/emcn/components'
import { cn } from '@/lib/core/utils/cn'
import type { FieldDiffStatus } from '@/lib/workflows/diff/types'
@@ -67,11 +67,6 @@ interface SubBlockProps {
disabled?: boolean
fieldDiffStatus?: FieldDiffStatus
allowExpandInPreview?: boolean
canonicalToggle?: {
mode: 'basic' | 'advanced'
disabled?: boolean
onToggle?: () => void
}
}
/**
@@ -187,11 +182,6 @@ const renderLabel = (
onSearchSubmit: () => void
onSearchCancel: () => void
searchInputRef: React.RefObject<HTMLInputElement | null>
},
canonicalToggle?: {
mode: 'basic' | 'advanced'
disabled?: boolean
onToggle?: () => void
}
): JSX.Element | null => {
if (config.type === 'switch') return null
@@ -199,12 +189,13 @@ const renderLabel = (
const required = isFieldRequired(config, subBlockValues)
const showWand = wandState?.isWandEnabled && !wandState.isPreview && !wandState.disabled
const showCanonicalToggle = !!canonicalToggle && !wandState?.isPreview
const canonicalToggleDisabled = wandState?.disabled || canonicalToggle?.disabled
return (
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
<Label className='flex items-center gap-[6px] whitespace-nowrap'>
<Label
className='flex items-center justify-between gap-[6px] pl-[2px]'
onClick={(e) => e.preventDefault()}
>
<div className='flex items-center gap-[6px] whitespace-nowrap'>
{config.title}
{required && <span className='ml-0.5'>*</span>}
{config.type === 'code' && config.language === 'json' && (
@@ -222,82 +213,58 @@ const renderLabel = (
</Tooltip.Content>
</Tooltip.Root>
)}
</Label>
<div className='flex items-center gap-[6px]'>
{showWand && (
<>
{!wandState.isSearchActive ? (
<Button
variant='active'
className='-my-1 h-5 px-2 py-0 text-[11px]'
onClick={wandState.onSearchClick}
>
Generate
</Button>
) : (
<div className='-my-1 flex items-center gap-[4px]'>
<Input
ref={wandState.searchInputRef}
value={wandState.isStreaming ? 'Generating...' : wandState.searchQuery}
onChange={(e) => wandState.onSearchChange(e.target.value)}
onBlur={wandState.onSearchBlur}
onKeyDown={(e) => {
if (
e.key === 'Enter' &&
wandState.searchQuery.trim() &&
!wandState.isStreaming
) {
wandState.onSearchSubmit()
} else if (e.key === 'Escape') {
wandState.onSearchCancel()
}
}}
disabled={wandState.isStreaming}
className={cn(
'h-5 max-w-[200px] flex-1 text-[11px]',
wandState.isStreaming && 'text-muted-foreground'
)}
placeholder='Generate with AI...'
/>
<Button
variant='tertiary'
disabled={!wandState.searchQuery.trim() || wandState.isStreaming}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={(e) => {
e.stopPropagation()
wandState.onSearchSubmit()
}}
className='h-[20px] w-[20px] flex-shrink-0 p-0'
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
</div>
)}
</>
)}
{showCanonicalToggle && (
<button
type='button'
className='flex h-[12px] w-[12px] flex-shrink-0 items-center justify-center bg-transparent p-0 disabled:cursor-not-allowed disabled:opacity-50'
onClick={canonicalToggle?.onToggle}
disabled={canonicalToggleDisabled}
aria-label={canonicalToggle?.mode === 'advanced' ? 'Use selector' : 'Enter manual ID'}
>
<ArrowLeftRight
className={cn(
'!h-[12px] !w-[12px]',
canonicalToggle?.mode === 'advanced'
? 'text-[var(--text-primary)]'
: 'text-[var(--text-secondary)]'
)}
/>
</button>
)}
</div>
</div>
{showWand && (
<>
{!wandState.isSearchActive ? (
<Button
variant='active'
className='-my-1 h-5 px-2 py-0 text-[11px]'
onClick={wandState.onSearchClick}
>
Generate
</Button>
) : (
<div className='-my-1 flex items-center gap-[4px]'>
<Input
ref={wandState.searchInputRef}
value={wandState.isStreaming ? 'Generating...' : wandState.searchQuery}
onChange={(e) => wandState.onSearchChange(e.target.value)}
onBlur={wandState.onSearchBlur}
onKeyDown={(e) => {
if (e.key === 'Enter' && wandState.searchQuery.trim() && !wandState.isStreaming) {
wandState.onSearchSubmit()
} else if (e.key === 'Escape') {
wandState.onSearchCancel()
}
}}
disabled={wandState.isStreaming}
className={cn(
'h-5 max-w-[200px] flex-1 text-[11px]',
wandState.isStreaming && 'text-muted-foreground'
)}
placeholder='Generate...'
/>
<Button
variant='tertiary'
disabled={!wandState.searchQuery.trim() || wandState.isStreaming}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={(e) => {
e.stopPropagation()
wandState.onSearchSubmit()
}}
className='h-[20px] w-[20px] flex-shrink-0 p-0'
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
</div>
)}
</>
)}
</Label>
)
}
@@ -320,9 +287,7 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool
prevProps.subBlockValues === nextProps.subBlockValues &&
prevProps.disabled === nextProps.disabled &&
prevProps.fieldDiffStatus === nextProps.fieldDiffStatus &&
prevProps.allowExpandInPreview === nextProps.allowExpandInPreview &&
prevProps.canonicalToggle?.mode === nextProps.canonicalToggle?.mode &&
prevProps.canonicalToggle?.disabled === nextProps.canonicalToggle?.disabled
prevProps.allowExpandInPreview === nextProps.allowExpandInPreview
)
}
@@ -351,7 +316,6 @@ function SubBlockComponent({
disabled = false,
fieldDiffStatus,
allowExpandInPreview,
canonicalToggle,
}: SubBlockProps): JSX.Element {
const [isValidJson, setIsValidJson] = useState(true)
const [isSearchActive, setIsSearchActive] = useState(false)
@@ -923,26 +887,20 @@ function SubBlockComponent({
return (
<div onMouseDown={handleMouseDown} className='subblock-content flex flex-col gap-[10px]'>
{renderLabel(
config,
isValidJson,
subBlockValues,
{
isSearchActive,
searchQuery,
isWandEnabled,
isPreview,
isStreaming: wandControlRef.current?.isWandStreaming ?? false,
disabled: isDisabled,
onSearchClick: handleSearchClick,
onSearchBlur: handleSearchBlur,
onSearchChange: handleSearchChange,
onSearchSubmit: handleSearchSubmit,
onSearchCancel: handleSearchCancel,
searchInputRef,
},
canonicalToggle
)}
{renderLabel(config, isValidJson, subBlockValues, {
isSearchActive,
searchQuery,
isWandEnabled,
isPreview,
isStreaming: wandControlRef.current?.isWandStreaming ?? false,
disabled: isDisabled,
onSearchClick: handleSearchClick,
onSearchBlur: handleSearchBlur,
onSearchChange: handleSearchChange,
onSearchSubmit: handleSearchSubmit,
onSearchCancel: handleSearchCancel,
searchInputRef,
})}
{renderInput()}
</div>
)

View File

@@ -1,15 +1,8 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BookOpen, Check, ChevronDown, ChevronUp, Pencil } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { BookOpen, Check, ChevronUp, Pencil, Settings } from 'lucide-react'
import { Button, Tooltip } from '@/components/emcn'
import {
buildCanonicalIndex,
hasAdvancedValues,
hasStandaloneAdvancedFields,
isCanonicalPair,
resolveCanonicalMode,
} from '@/lib/workflows/subblocks/visibility'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
ConnectionBlocks,
@@ -27,7 +20,6 @@ import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/compo
import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { getBlock } from '@/blocks/registry'
import type { SubBlockType } from '@/blocks/types'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePanelEditorStore } from '@/stores/panel'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -97,65 +89,17 @@ export function Editor() {
)
)
const subBlocksForCanonical = useMemo(() => {
const subBlocks = blockConfig?.subBlocks || []
if (!triggerMode) return subBlocks
return subBlocks.filter(
(subBlock) =>
subBlock.mode === 'trigger' || subBlock.type === ('trigger-config' as SubBlockType)
)
}, [blockConfig?.subBlocks, triggerMode])
const canonicalIndex = useMemo(
() => buildCanonicalIndex(subBlocksForCanonical),
[subBlocksForCanonical]
)
const canonicalModeOverrides = currentBlock?.data?.canonicalModes
const advancedValuesPresent = hasAdvancedValues(
subBlocksForCanonical,
blockSubBlockValues,
canonicalIndex
)
const displayAdvancedOptions = advancedMode || advancedValuesPresent
const hasAdvancedOnlyFields = useMemo(
() => hasStandaloneAdvancedFields(subBlocksForCanonical, canonicalIndex),
[subBlocksForCanonical, canonicalIndex]
)
// Get subblock layout using custom hook
const { subBlocks, stateToUse: subBlockState } = useEditorSubblockLayout(
blockConfig || ({} as any),
currentBlockId || '',
displayAdvancedOptions,
advancedMode,
triggerMode,
activeWorkflowId,
blockSubBlockValues,
currentWorkflow.isSnapshotView
)
/**
* Partitions subBlocks into regular fields and standalone advanced-only fields.
* Standalone advanced fields have mode 'advanced' and are not part of a canonical swap pair.
*/
const { regularSubBlocks, advancedOnlySubBlocks } = useMemo(() => {
const regular: typeof subBlocks = []
const advancedOnly: typeof subBlocks = []
for (const subBlock of subBlocks) {
const isStandaloneAdvanced =
subBlock.mode === 'advanced' && !canonicalIndex.canonicalIdBySubBlockId[subBlock.id]
if (isStandaloneAdvanced) {
advancedOnly.push(subBlock)
} else {
regular.push(subBlock)
}
}
return { regularSubBlocks: regular, advancedOnlySubBlocks: advancedOnly }
}, [subBlocks, canonicalIndex.canonicalIdBySubBlockId])
// Get block connections
const { incomingConnections, hasIncomingConnections } = useBlockConnections(currentBlockId || '')
@@ -165,23 +109,21 @@ export function Editor() {
})
// Collaborative actions
const {
collaborativeSetBlockCanonicalMode,
collaborativeUpdateBlockName,
collaborativeToggleBlockAdvancedMode,
} = useCollaborativeWorkflow()
// Advanced mode toggle handler
const handleToggleAdvancedMode = useCallback(() => {
if (!currentBlockId || !userPermissions.canEdit) return
collaborativeToggleBlockAdvancedMode(currentBlockId)
}, [currentBlockId, userPermissions.canEdit, collaborativeToggleBlockAdvancedMode])
const { collaborativeToggleBlockAdvancedMode, collaborativeUpdateBlockName } =
useCollaborativeWorkflow()
// Rename state
const [isRenaming, setIsRenaming] = useState(false)
const [editedName, setEditedName] = useState('')
const nameInputRef = useRef<HTMLInputElement>(null)
// Mode toggle handlers
const handleToggleAdvancedMode = useCallback(() => {
if (currentBlockId && userPermissions.canEdit) {
collaborativeToggleBlockAdvancedMode(currentBlockId)
}
}, [currentBlockId, userPermissions.canEdit, collaborativeToggleBlockAdvancedMode])
/**
* Handles starting the rename process.
*/
@@ -241,6 +183,9 @@ export function Editor() {
}
}
// Check if block has advanced mode or trigger mode available
const hasAdvancedMode = blockConfig?.subBlocks?.some((sb) => sb.mode === 'advanced')
// Determine if connections are at minimum height (collapsed state)
const isConnectionsAtMinHeight = connectionsHeight <= 35
@@ -333,6 +278,25 @@ export function Editor() {
</Tooltip.Content>
</Tooltip.Root>
)} */}
{/* Mode toggles - Only show for regular blocks, not subflows */}
{currentBlock && !isSubflow && hasAdvancedMode && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='p-0'
onClick={handleToggleAdvancedMode}
disabled={!userPermissions.canEdit}
aria-label='Toggle advanced mode'
>
<Settings className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>Advanced mode</p>
</Tooltip.Content>
</Tooltip.Root>
)}
{currentBlock && (isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink) && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
@@ -378,111 +342,14 @@ export function Editor() {
ref={subBlocksRef}
className='subblocks-section flex flex-1 flex-col overflow-hidden'
>
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[8px] pt-[12px] pb-[8px] [overflow-anchor:none]'>
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[8px] pt-[12px] pb-[8px]'>
{subBlocks.length === 0 ? (
<div className='flex h-full items-center justify-center text-center text-[#8D8D8D] text-[13px]'>
This block has no subblocks
</div>
) : (
<div className='flex flex-col'>
{regularSubBlocks.map((subBlock, index) => {
const stableKey = getSubBlockStableKey(
currentBlockId || '',
subBlock,
subBlockState
)
const canonicalId = canonicalIndex.canonicalIdBySubBlockId[subBlock.id]
const canonicalGroup = canonicalId
? canonicalIndex.groupsById[canonicalId]
: undefined
const isCanonicalSwap = isCanonicalPair(canonicalGroup)
const canonicalMode =
canonicalGroup && isCanonicalSwap
? resolveCanonicalMode(
canonicalGroup,
blockSubBlockValues,
canonicalModeOverrides
)
: undefined
const showDivider =
index < regularSubBlocks.length - 1 ||
(!hasAdvancedOnlyFields && index < subBlocks.length - 1)
return (
<div key={stableKey} className='subblock-row'>
<SubBlock
blockId={currentBlockId}
config={subBlock}
isPreview={false}
subBlockValues={subBlockState}
disabled={!userPermissions.canEdit}
fieldDiffStatus={undefined}
allowExpandInPreview={false}
canonicalToggle={
isCanonicalSwap && canonicalMode && canonicalId
? {
mode: canonicalMode,
disabled: !userPermissions.canEdit,
onToggle: () => {
if (!currentBlockId) return
const nextMode =
canonicalMode === 'advanced' ? 'basic' : 'advanced'
collaborativeSetBlockCanonicalMode(
currentBlockId,
canonicalId,
nextMode
)
},
}
: undefined
}
/>
{showDivider && (
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
)}
</div>
)
})}
{hasAdvancedOnlyFields && userPermissions.canEdit && (
<div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'>
<div
className='h-[1.25px] flex-1'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
<button
type='button'
onClick={handleToggleAdvancedMode}
className='flex items-center gap-[6px] whitespace-nowrap font-medium text-[13px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
>
{displayAdvancedOptions ? 'Hide advanced fields' : 'Show advanced fields'}
<ChevronDown
className={`h-[14px] w-[14px] transition-transform duration-200 ${displayAdvancedOptions ? 'rotate-180' : ''}`}
/>
</button>
<div
className='h-[1.25px] flex-1'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
)}
{advancedOnlySubBlocks.map((subBlock, index) => {
{subBlocks.map((subBlock, index) => {
const stableKey = getSubBlockStableKey(
currentBlockId || '',
subBlock,
@@ -500,7 +367,7 @@ export function Editor() {
fieldDiffStatus={undefined}
allowExpandInPreview={false}
/>
{index < advancedOnlySubBlocks.length - 1 && (
{index < subBlocks.length - 1 && (
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div
className='h-[1.25px]'

View File

@@ -1,10 +1,5 @@
import { useCallback, useMemo } from 'react'
import {
buildCanonicalIndex,
evaluateSubBlockCondition,
isSubBlockFeatureEnabled,
isSubBlockVisibleForMode,
} from '@/lib/workflows/subblocks/visibility'
import { useMemo } from 'react'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import type { BlockConfig, SubBlockConfig, SubBlockType } from '@/blocks/types'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
import { mergeSubblockState } from '@/stores/workflows/utils'
@@ -32,10 +27,6 @@ export function useEditorSubblockLayout(
blockSubBlockValues: Record<string, any>,
isSnapshotView: boolean
) {
const blockDataFromStore = useWorkflowStore(
useCallback((state) => state.blocks?.[blockId]?.data, [blockId])
)
return useMemo(() => {
// Guard against missing config or block selection
if (!config || !Array.isArray((config as any).subBlocks) || !blockId) {
@@ -55,7 +46,6 @@ export function useEditorSubblockLayout(
const mergedState = mergedMap ? mergedMap[blockId] : undefined
const mergedSubBlocks = mergedState?.subBlocks || {}
const blockData = isSnapshotView ? mergedState?.data || {} : blockDataFromStore || {}
const stateToUse = Object.keys(mergedSubBlocks).reduce(
(acc, key) => {
@@ -79,29 +69,13 @@ export function useEditorSubblockLayout(
}
// Filter visible blocks and those that meet their conditions
const rawValues = Object.entries(stateToUse).reduce<Record<string, unknown>>(
(acc, [key, entry]) => {
acc[key] = entry?.value
return acc
},
{}
)
const subBlocksForCanonical = displayTriggerMode
? (config.subBlocks || []).filter(
(subBlock) =>
subBlock.mode === 'trigger' || subBlock.type === ('trigger-config' as SubBlockType)
)
: config.subBlocks || []
const canonicalIndex = buildCanonicalIndex(subBlocksForCanonical)
const effectiveAdvanced = displayAdvancedMode
const canonicalModeOverrides = blockData?.canonicalModes
const visibleSubBlocks = (config.subBlocks || []).filter((block) => {
if (block.hidden) return false
// Check required feature if specified - declarative feature gating
if (!isSubBlockFeatureEnabled(block)) return false
if (block.requiresFeature && !isTruthy(getEnv(block.requiresFeature))) {
return false
}
// Special handling for trigger-config type (legacy trigger configuration UI)
if (block.type === ('trigger-config' as SubBlockType)) {
@@ -110,8 +84,13 @@ export function useEditorSubblockLayout(
}
// Filter by mode if specified
if (block.mode === 'trigger') {
if (!displayTriggerMode) return false
if (block.mode) {
if (block.mode === 'basic' && displayAdvancedMode) return false
if (block.mode === 'advanced' && !displayAdvancedMode) return false
if (block.mode === 'trigger') {
// Show trigger mode blocks only when in trigger mode
if (!displayTriggerMode) return false
}
}
// When in trigger mode, hide blocks that don't have mode: 'trigger'
@@ -119,22 +98,42 @@ export function useEditorSubblockLayout(
return false
}
if (
!isSubBlockVisibleForMode(
block,
effectiveAdvanced,
canonicalIndex,
rawValues,
canonicalModeOverrides
)
) {
return false
}
// If there's no condition, the block should be shown
if (!block.condition) return true
return evaluateSubBlockCondition(block.condition, rawValues)
// If condition is a function, call it to get the actual condition object
const actualCondition =
typeof block.condition === 'function' ? block.condition() : block.condition
// Get the values of the fields this block depends on from the appropriate state
const fieldValue = stateToUse[actualCondition.field]?.value
const andFieldValue = actualCondition.and
? stateToUse[actualCondition.and.field]?.value
: undefined
// Check if the condition value is an array
const isValueMatch = Array.isArray(actualCondition.value)
? fieldValue != null &&
(actualCondition.not
? !actualCondition.value.includes(fieldValue as string | number | boolean)
: actualCondition.value.includes(fieldValue as string | number | boolean))
: actualCondition.not
? fieldValue !== actualCondition.value
: fieldValue === actualCondition.value
// Check both conditions if 'and' is present
const isAndValueMatch =
!actualCondition.and ||
(Array.isArray(actualCondition.and.value)
? andFieldValue != null &&
(actualCondition.and.not
? !actualCondition.and.value.includes(andFieldValue as string | number | boolean)
: actualCondition.and.value.includes(andFieldValue as string | number | boolean))
: actualCondition.and.not
? andFieldValue !== actualCondition.and.value
: andFieldValue === actualCondition.and.value)
return isValueMatch && isAndValueMatch
})
return { subBlocks: visibleSubBlocks, stateToUse }
@@ -148,6 +147,5 @@ export function useEditorSubblockLayout(
blockSubBlockValues,
activeWorkflowId,
isSnapshotView,
blockDataFromStore,
])
}

View File

@@ -556,17 +556,14 @@ export function Panel() {
<ModalHeader>Delete Workflow</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>
{currentWorkflow?.name ?? 'this workflow'}
</span>
? This will permanently remove all associated blocks, executions, and configuration.{' '}
Deleting this workflow will permanently remove all associated blocks, executions, and
configuration.{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
<Button
variant='default'
variant='active'
onClick={() => setIsDeleteModalOpen(false)}
disabled={isDeleting}
>

View File

@@ -3,18 +3,11 @@ import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
import { Badge, Tooltip } from '@/components/emcn'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createMcpToolId } from '@/lib/mcp/utils'
import { getProviderIdFromServiceId } from '@/lib/oauth'
import {
buildCanonicalIndex,
evaluateSubBlockCondition,
hasAdvancedValues,
isSubBlockFeatureEnabled,
isSubBlockVisibleForMode,
resolveDependencyValue,
} from '@/lib/workflows/subblocks/visibility'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar'
import {
@@ -337,9 +330,6 @@ const SubBlockRow = ({
workflowId,
blockId,
allSubBlockValues,
displayAdvancedOptions,
canonicalIndex,
canonicalModeOverrides,
}: {
title: string
value?: string
@@ -349,9 +339,6 @@ const SubBlockRow = ({
workflowId?: string
blockId?: string
allSubBlockValues?: Record<string, { value: unknown }>
displayAdvancedOptions?: boolean
canonicalIndex?: ReturnType<typeof buildCanonicalIndex>
canonicalModeOverrides?: Record<string, 'basic' | 'advanced'>
}) => {
const getStringValue = useCallback(
(key?: string): string | undefined => {
@@ -362,43 +349,17 @@ const SubBlockRow = ({
[allSubBlockValues]
)
const rawValues = useMemo(() => {
if (!allSubBlockValues) return {}
return Object.entries(allSubBlockValues).reduce<Record<string, unknown>>(
(acc, [key, entry]) => {
acc[key] = entry?.value
return acc
},
{}
)
}, [allSubBlockValues])
const dependencyValues = useMemo(() => {
const fields = getDependsOnFields(subBlock?.dependsOn)
if (!fields.length) return {}
return fields.reduce<Record<string, string>>((accumulator, dependency) => {
const dependencyValue = resolveDependencyValue(
dependency,
rawValues,
canonicalIndex || buildCanonicalIndex([]),
canonicalModeOverrides
)
const dependencyString =
typeof dependencyValue === 'string' && dependencyValue.length > 0
? dependencyValue
: undefined
if (dependencyString) {
accumulator[dependency] = dependencyString
const dependencyValue = getStringValue(dependency)
if (dependencyValue) {
accumulator[dependency] = dependencyValue
}
return accumulator
}, {})
}, [
canonicalIndex,
canonicalModeOverrides,
displayAdvancedOptions,
rawValues,
subBlock?.dependsOn,
])
}, [getStringValue, subBlock?.dependsOn])
const credentialSourceId =
subBlock?.type === 'oauth-input' && typeof rawValue === 'string' ? rawValue : undefined
@@ -622,8 +583,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
const { mutate: deployChildWorkflow, isPending: isDeploying } = useDeployChildWorkflow()
const userPermissions = useUserPermissionsContext()
const currentStoreBlock = currentWorkflow.getBlockById(id)
const isStarterBlock = type === 'starter'
@@ -642,8 +601,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
[activeWorkflowId, id]
)
)
const canonicalIndex = useMemo(() => buildCanonicalIndex(config.subBlocks), [config.subBlocks])
const canonicalModeOverrides = currentStoreBlock?.data?.canonicalModes
const subBlockRowsData = useMemo(() => {
const rows: SubBlockConfig[][] = []
@@ -666,23 +623,16 @@ export const WorkflowBlock = memo(function WorkflowBlock({
{} as Record<string, { value: unknown }>
)
const rawValues = Object.entries(stateToUse).reduce<Record<string, unknown>>(
(acc, [key, entry]) => {
acc[key] = entry?.value
return acc
},
{}
)
const effectiveAdvanced = userPermissions.canEdit
? displayAdvancedMode
: displayAdvancedMode || hasAdvancedValues(config.subBlocks, rawValues, canonicalIndex)
const effectiveAdvanced = displayAdvancedMode
const effectiveTrigger = displayTriggerMode
const visibleSubBlocks = config.subBlocks.filter((block) => {
if (block.hidden) return false
if (block.hideFromPreview) return false
if (!isSubBlockFeatureEnabled(block)) return false
if (block.requiresFeature && !isTruthy(getEnv(block.requiresFeature))) {
return false
}
const isPureTriggerBlock = config?.triggers?.enabled && config.category === 'triggers'
@@ -700,21 +650,40 @@ export const WorkflowBlock = memo(function WorkflowBlock({
}
}
if (
!isSubBlockVisibleForMode(
block,
effectiveAdvanced,
canonicalIndex,
rawValues,
canonicalModeOverrides
)
) {
return false
}
if (block.mode === 'basic' && effectiveAdvanced) return false
if (block.mode === 'advanced' && !effectiveAdvanced) return false
if (!block.condition) return true
return evaluateSubBlockCondition(block.condition, rawValues)
const actualCondition =
typeof block.condition === 'function' ? block.condition() : block.condition
const fieldValue = stateToUse[actualCondition.field]?.value
const andFieldValue = actualCondition.and
? stateToUse[actualCondition.and.field]?.value
: undefined
const isValueMatch = Array.isArray(actualCondition.value)
? fieldValue != null &&
(actualCondition.not
? !actualCondition.value.includes(fieldValue as string | number | boolean)
: actualCondition.value.includes(fieldValue as string | number | boolean))
: actualCondition.not
? fieldValue !== actualCondition.value
: fieldValue === actualCondition.value
const isAndValueMatch =
!actualCondition.and ||
(Array.isArray(actualCondition.and.value)
? andFieldValue != null &&
(actualCondition.and.not
? !actualCondition.and.value.includes(andFieldValue as string | number | boolean)
: actualCondition.and.value.includes(andFieldValue as string | number | boolean))
: actualCondition.and.not
? andFieldValue !== actualCondition.and.value
: andFieldValue === actualCondition.and.value)
return isValueMatch && isAndValueMatch
})
visibleSubBlocks.forEach((block) => {
@@ -746,33 +715,12 @@ export const WorkflowBlock = memo(function WorkflowBlock({
data.subBlockValues,
currentWorkflow.isDiffMode,
currentBlock,
canonicalModeOverrides,
userPermissions.canEdit,
canonicalIndex,
blockSubBlockValues,
activeWorkflowId,
])
const subBlockRows = subBlockRowsData.rows
const subBlockState = subBlockRowsData.stateToUse
const effectiveAdvanced = useMemo(() => {
const rawValues = Object.entries(subBlockState).reduce<Record<string, unknown>>(
(acc, [key, entry]) => {
acc[key] = entry?.value
return acc
},
{}
)
return userPermissions.canEdit
? displayAdvancedMode
: displayAdvancedMode || hasAdvancedValues(config.subBlocks, rawValues, canonicalIndex)
}, [
subBlockState,
displayAdvancedMode,
config.subBlocks,
canonicalIndex,
userPermissions.canEdit,
])
/**
* Determine if block has content below the header (subblocks or error row).
@@ -935,6 +883,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
const showWebhookIndicator = (isStarterBlock || isWebhookTriggerBlock) && isWebhookConfigured
const shouldShowScheduleBadge =
type === 'schedule' && !isLoadingScheduleInfo && scheduleInfo !== null
const userPermissions = useUserPermissionsContext()
const isWorkflowSelector = type === 'workflow' || type === 'workflow_input'
return (
@@ -1146,9 +1095,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
workflowId={currentWorkflowId}
blockId={id}
allSubBlockValues={subBlockState}
displayAdvancedOptions={effectiveAdvanced}
canonicalIndex={canonicalIndex}
canonicalModeOverrides={canonicalModeOverrides}
/>
)
})

View File

@@ -2,7 +2,6 @@ import { useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
import { SYSTEM_REFERENCE_PREFIXES } from '@/lib/workflows/sanitization/references'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import { normalizeName } from '@/executor/constants'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { Loop, Parallel } from '@/stores/workflows/workflow/types'
@@ -27,7 +26,9 @@ export function useAccessibleReferencePrefixes(blockId?: string | null): Set<str
const accessibleIds = new Set<string>(ancestorIds)
accessibleIds.add(blockId)
const starterBlock = Object.values(blocks).find((block) => isValidStartBlockType(block.type))
const starterBlock = Object.values(blocks).find(
(block) => block.type === 'starter' || block.type === 'start_trigger'
)
if (starterBlock && ancestorIds.includes(starterBlock.id)) {
accessibleIds.add(starterBlock.id)
}

View File

@@ -700,23 +700,7 @@ const WorkflowContent = React.memo(() => {
triggerMode,
})
const subBlockValues: Record<string, Record<string, unknown>> = {}
if (block.subBlocks && Object.keys(block.subBlocks).length > 0) {
subBlockValues[id] = {}
for (const [subBlockId, subBlock] of Object.entries(block.subBlocks)) {
if (subBlock.value !== null && subBlock.value !== undefined) {
subBlockValues[id][subBlockId] = subBlock.value
}
}
}
collaborativeBatchAddBlocks(
[block],
autoConnectEdge ? [autoConnectEdge] : [],
{},
{},
subBlockValues
)
collaborativeBatchAddBlocks([block], autoConnectEdge ? [autoConnectEdge] : [], {}, {}, {})
usePanelEditorStore.getState().setCurrentBlockId(id)
},
[collaborativeBatchAddBlocks, setSelectedEdges]

View File

@@ -14,13 +14,6 @@ import { ReactFlowProvider } from 'reactflow'
import { Badge, Button, ChevronDown, Code, Combobox, Input, Label } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { extractReferencePrefixes } from '@/lib/workflows/sanitization/references'
import {
buildCanonicalIndex,
evaluateSubBlockCondition,
hasAdvancedValues,
isSubBlockFeatureEnabled,
isSubBlockVisibleForMode,
} from '@/lib/workflows/subblocks/visibility'
import { SnapshotContextMenu } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/components'
import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
@@ -31,6 +24,56 @@ import { navigatePath } from '@/executor/variables/resolvers/reference'
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
/**
* Evaluate whether a subblock's condition is met based on current values.
*/
function evaluateCondition(
condition: SubBlockConfig['condition'],
subBlockValues: Record<string, { value: unknown } | unknown>
): boolean {
if (!condition) return true
const actualCondition = typeof condition === 'function' ? condition() : condition
const fieldValueObj = subBlockValues[actualCondition.field]
const fieldValue =
fieldValueObj && typeof fieldValueObj === 'object' && 'value' in fieldValueObj
? (fieldValueObj as { value: unknown }).value
: fieldValueObj
const conditionValues = Array.isArray(actualCondition.value)
? actualCondition.value
: [actualCondition.value]
let isMatch = conditionValues.some((v) => v === fieldValue)
if (actualCondition.not) {
isMatch = !isMatch
}
if (actualCondition.and && isMatch) {
const andFieldValueObj = subBlockValues[actualCondition.and.field]
const andFieldValue =
andFieldValueObj && typeof andFieldValueObj === 'object' && 'value' in andFieldValueObj
? (andFieldValueObj as { value: unknown }).value
: andFieldValueObj
const andConditionValues = Array.isArray(actualCondition.and.value)
? actualCondition.and.value
: [actualCondition.and.value]
let andMatch = andConditionValues.some((v) => v === andFieldValue)
if (actualCondition.and.not) {
andMatch = !andMatch
}
isMatch = isMatch && andMatch
}
return isMatch
}
/**
* Format a value for display as JSON string
*/
@@ -1079,44 +1122,15 @@ function BlockDetailsSidebarContent({
)
}
const rawValues = useMemo(() => {
return Object.entries(subBlockValues).reduce<Record<string, unknown>>((acc, [key, entry]) => {
if (entry && typeof entry === 'object' && 'value' in entry) {
acc[key] = (entry as { value: unknown }).value
} else {
acc[key] = entry
}
return acc
}, {})
}, [subBlockValues])
const canonicalIndex = useMemo(
() => buildCanonicalIndex(blockConfig.subBlocks),
[blockConfig.subBlocks]
)
const canonicalModeOverrides = block.data?.canonicalModes
const effectiveAdvanced =
(block.advancedMode ?? false) ||
hasAdvancedValues(blockConfig.subBlocks, rawValues, canonicalIndex)
const visibleSubBlocks = blockConfig.subBlocks.filter((subBlock) => {
if (subBlock.hidden || subBlock.hideFromPreview) return false
// Only filter out trigger-mode subblocks for non-trigger blocks
// Trigger-only blocks (category 'triggers') should display their trigger subblocks
if (subBlock.mode === 'trigger' && blockConfig.category !== 'triggers') return false
if (!isSubBlockFeatureEnabled(subBlock)) return false
if (
!isSubBlockVisibleForMode(
subBlock,
effectiveAdvanced,
canonicalIndex,
rawValues,
canonicalModeOverrides
)
) {
return false
if (subBlock.condition) {
return evaluateCondition(subBlock.condition, subBlockValues)
}
return evaluateSubBlockCondition(subBlock.condition, rawValues)
return true
})
const statusVariant =

View File

@@ -420,7 +420,7 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent size='md'>
<ModalContent>
<ModalHeader>Help &amp; Support</ModalHeader>
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>

View File

@@ -1069,7 +1069,7 @@ export function AccessControl() {
</Modal>
<Modal open={showUnsavedChanges} onOpenChange={setShowUnsavedChanges}>
<ModalContent size='sm'>
<ModalContent className='w-[400px]'>
<ModalHeader>Unsaved Changes</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
@@ -1185,7 +1185,7 @@ export function AccessControl() {
</div>
<Modal open={showCreateModal} onOpenChange={handleCloseCreateModal}>
<ModalContent size='sm'>
<ModalContent className='w-[400px]'>
<ModalHeader>Create Permission Group</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-[12px]'>
@@ -1237,7 +1237,7 @@ export function AccessControl() {
</Modal>
<Modal open={!!deletingGroup} onOpenChange={() => setDeletingGroup(null)}>
<ModalContent size='sm'>
<ModalContent className='w-[400px]'>
<ModalHeader>Delete Permission Group</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>

View File

@@ -392,7 +392,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
{/* Delete Confirmation Dialog */}
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent size='sm'>
<ModalContent className='w-[400px]'>
<ModalHeader>Delete API key</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>

View File

@@ -112,7 +112,7 @@ export function CreateApiKeyModal({
<>
{/* Create API Key Dialog */}
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent size='sm'>
<ModalContent className='w-[400px]'>
<ModalHeader>Create new API key</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
@@ -176,7 +176,7 @@ export function CreateApiKeyModal({
data-form-type='other'
/>
{createError && (
<p className='text-[12px] text-[var(--text-error)] leading-tight'>
<p className='text-[11px] text-[var(--text-error)] leading-tight'>
{createError}
</p>
)}
@@ -215,7 +215,7 @@ export function CreateApiKeyModal({
}
}}
>
<ModalContent size='sm'>
<ModalContent className='w-[400px]'>
<ModalHeader>Your API key has been created</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>

View File

@@ -276,7 +276,7 @@ export function BYOK() {
</Button>
</div>
{error && (
<p className='text-[12px] text-[var(--text-error)] leading-tight'>{error}</p>
<p className='text-[11px] text-[var(--text-error)] leading-tight'>{error}</p>
)}
</div>
</ModalBody>
@@ -306,7 +306,7 @@ export function BYOK() {
</Modal>
<Modal open={!!deleteConfirmProvider} onOpenChange={() => setDeleteConfirmProvider(null)}>
<ModalContent size='sm'>
<ModalContent className='w-[400px]'>
<ModalHeader>Delete API Key</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>

View File

@@ -211,7 +211,7 @@ export function Copilot() {
{/* Create API Key Dialog */}
<Modal open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<ModalContent size='sm'>
<ModalContent className='w-[400px]'>
<ModalHeader>Create new API key</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
@@ -234,7 +234,7 @@ export function Copilot() {
autoFocus
/>
{createError && (
<p className='text-[12px] text-[var(--text-error)] leading-tight'>{createError}</p>
<p className='text-[11px] text-[var(--text-error)] leading-tight'>{createError}</p>
)}
</div>
</ModalBody>
@@ -273,7 +273,7 @@ export function Copilot() {
}
}}
>
<ModalContent size='sm'>
<ModalContent className='w-[400px]'>
<ModalHeader>Your API key has been created</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
@@ -310,7 +310,7 @@ export function Copilot() {
{/* Delete Confirmation Dialog */}
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent size='sm'>
<ModalContent className='w-[400px]'>
<ModalHeader>Delete API key</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>

View File

@@ -824,7 +824,7 @@ export function CredentialSets() {
{/* Create Polling Group Modal */}
<Modal open={showCreateModal} onOpenChange={handleCloseCreateModal}>
<ModalContent size='sm'>
<ModalContent className='w-[400px]'>
<ModalHeader>Create Polling Group</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-[12px]'>
@@ -897,7 +897,7 @@ export function CredentialSets() {
{/* Leave Confirmation Modal */}
<Modal open={!!leavingMembership} onOpenChange={() => setLeavingMembership(null)}>
<ModalContent size='sm'>
<ModalContent className='w-[400px]'>
<ModalHeader>Leave Polling Group</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
@@ -925,7 +925,7 @@ export function CredentialSets() {
{/* Delete Confirmation Modal */}
<Modal open={!!deletingSet} onOpenChange={() => setDeletingSet(null)}>
<ModalContent size='sm'>
<ModalContent className='w-[400px]'>
<ModalHeader>Delete Polling Group</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>

View File

@@ -206,7 +206,7 @@ export function CustomTools() {
/>
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent size='sm'>
<ModalContent className='w-[400px]'>
<ModalHeader>Delete Custom Tool</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>

View File

@@ -821,7 +821,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
</div>
<Modal open={showUnsavedChanges} onOpenChange={setShowUnsavedChanges}>
<ModalContent size='sm'>
<ModalContent className='w-[400px]'>
<ModalHeader>Unsaved Changes</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>

View File

@@ -390,7 +390,7 @@ export function Integrations({ onOpenChange, registerCloseHandler }: Integration
</div>
<Modal open={showDisconnectDialog} onOpenChange={setShowDisconnectDialog}>
<ModalContent size='sm'>
<ModalContent className='w-[400px]'>
<ModalHeader>Disconnect Service</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>

View File

@@ -3,17 +3,13 @@ import { Label } from '@/components/emcn'
interface FormFieldProps {
label: string
children: React.ReactNode
optional?: boolean
}
export function FormField({ label, children, optional }: FormFieldProps) {
export function FormField({ label, children }: FormFieldProps) {
return (
<div className='flex items-center justify-between gap-[12px]'>
<Label className='w-[100px] shrink-0 font-medium text-[13px] text-[var(--text-secondary)]'>
{label}
{optional && (
<span className='ml-1 font-normal text-[11px] text-[var(--text-muted)]'>(optional)</span>
)}
</Label>
<div className='relative flex-1'>{children}</div>
</div>

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ChevronDown, Plus, Search, X } from 'lucide-react'
import { Plus, Search, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Badge,
@@ -77,17 +77,10 @@ interface EnvVarDropdownConfig {
onClose: () => void
}
interface McpToolSchema {
type: 'object'
properties?: Record<string, unknown>
required?: string[]
}
interface McpTool {
name: string
description?: string
serverId: string
inputSchema?: McpToolSchema
}
interface McpServer {
@@ -388,7 +381,6 @@ export function MCP({ initialServerId }: MCPProps) {
const [refreshingServers, setRefreshingServers] = useState<
Record<string, { status: 'refreshing' | 'refreshed'; workflowsUpdated?: number }>
>({})
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set())
const [showEnvVars, setShowEnvVars] = useState(false)
const [envSearchTerm, setEnvSearchTerm] = useState('')
@@ -677,22 +669,6 @@ export function MCP({ initialServerId }: MCPProps) {
*/
const handleBackToList = useCallback(() => {
setSelectedServerId(null)
setExpandedTools(new Set())
}, [])
/**
* Toggles the expanded state of a tool's parameters.
*/
const toggleToolExpanded = useCallback((toolName: string) => {
setExpandedTools((prev) => {
const newSet = new Set(prev)
if (newSet.has(toolName)) {
newSet.delete(toolName)
} else {
newSet.add(toolName)
}
return newSet
})
}, [])
/**
@@ -867,113 +843,38 @@ export function MCP({ initialServerId }: MCPProps) {
{tools.map((tool) => {
const issues = getStoredToolIssues(server.id, tool.name)
const affectedWorkflows = issues.map((i) => i.workflowName)
const isExpanded = expandedTools.has(tool.name)
const hasParams =
tool.inputSchema?.properties &&
Object.keys(tool.inputSchema.properties).length > 0
const requiredParams = tool.inputSchema?.required || []
return (
<div
key={tool.name}
className='overflow-hidden rounded-[6px] border bg-[var(--surface-3)]'
className='rounded-[6px] border bg-[var(--surface-3)] px-[10px] py-[8px]'
>
<button
type='button'
onClick={() => hasParams && toggleToolExpanded(tool.name)}
className={cn(
'flex w-full items-start justify-between px-[10px] py-[8px] text-left',
hasParams && 'cursor-pointer hover:bg-[var(--surface-4)]'
<div className='flex items-center gap-[8px]'>
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
{tool.name}
</p>
{issues.length > 0 && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div>
<Badge
variant={getIssueBadgeVariant(issues[0].issue)}
size='sm'
className='cursor-help'
>
{getIssueBadgeLabel(issues[0].issue)}
</Badge>
</div>
</Tooltip.Trigger>
<Tooltip.Content>
Update in: {affectedWorkflows.join(', ')}
</Tooltip.Content>
</Tooltip.Root>
)}
disabled={!hasParams}
>
<div className='flex-1'>
<div className='flex items-center gap-[8px]'>
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
{tool.name}
</p>
{issues.length > 0 && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div>
<Badge
variant={getIssueBadgeVariant(issues[0].issue)}
size='sm'
className='cursor-help'
>
{getIssueBadgeLabel(issues[0].issue)}
</Badge>
</div>
</Tooltip.Trigger>
<Tooltip.Content>
Update in: {affectedWorkflows.join(', ')}
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
{tool.description && (
<p className='mt-[4px] text-[13px] text-[var(--text-tertiary)]'>
{tool.description}
</p>
)}
</div>
{hasParams && (
<ChevronDown
className={cn(
'mt-[2px] h-[14px] w-[14px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-200',
isExpanded && 'rotate-180'
)}
/>
)}
</button>
{isExpanded && hasParams && (
<div className='border-[var(--border-1)] border-t bg-[var(--surface-2)] px-[10px] py-[8px]'>
<p className='mb-[6px] font-medium text-[11px] text-[var(--text-muted)] uppercase tracking-wide'>
Parameters
</p>
<div className='flex flex-col gap-[6px]'>
{Object.entries(tool.inputSchema!.properties!).map(
([paramName, param]) => {
const isRequired = requiredParams.includes(paramName)
const paramType =
typeof param === 'object' && param !== null
? (param as { type?: string }).type || 'any'
: 'any'
const paramDesc =
typeof param === 'object' && param !== null
? (param as { description?: string }).description
: undefined
return (
<div
key={paramName}
className='rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-3)] px-[8px] py-[6px]'
>
<div className='flex items-center gap-[6px]'>
<span className='font-medium text-[12px] text-[var(--text-primary)]'>
{paramName}
</span>
<Badge variant='outline' size='sm'>
{paramType}
</Badge>
{isRequired && (
<Badge variant='default' size='sm'>
required
</Badge>
)}
</div>
{paramDesc && (
<p className='mt-[3px] text-[11px] text-[var(--text-tertiary)] leading-relaxed'>
{paramDesc}
</p>
)}
</div>
)
}
)}
</div>
</div>
</div>
{tool.description && (
<p className='mt-[4px] text-[13px] text-[var(--text-tertiary)]'>
{tool.description}
</p>
)}
</div>
)
@@ -1170,7 +1071,7 @@ export function MCP({ initialServerId }: MCPProps) {
</div>
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent size='sm'>
<ModalContent className='w-[400px]'>
<ModalHeader>Delete MCP Server</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>

View File

@@ -245,7 +245,10 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
? 'Your subscription is set to cancel at the end of the billing period. Would you like to keep your subscription active?'
: `You'll be redirected to Stripe to manage your subscription. You'll keep access until ${formatDate(
periodEndDate
)}, then downgrade to free plan. You can restore your subscription at any time.`}
)}, then downgrade to free plan.`}{' '}
{!isCancelAtPeriodEnd && (
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
)}
</p>
{!isCancelAtPeriodEnd && (
@@ -263,7 +266,7 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
</ModalBody>
<ModalFooter>
<Button
variant='default'
variant='active'
onClick={isCancelAtPeriodEnd ? () => setIsDialogOpen(false) : handleKeep}
disabled={isLoading}
>

View File

@@ -183,7 +183,7 @@ export function MemberInvitationCard({
aria-autocomplete='none'
/>
{emailError && (
<p className='mt-1 text-[12px] text-[var(--text-error)] leading-tight'>
<p className='mt-1 text-[11px] text-[var(--text-error)] leading-tight'>
{emailError}
</p>
)}
@@ -295,7 +295,7 @@ export function MemberInvitationCard({
{/* Invitation error - inline */}
{invitationError && (
<p className='text-[12px] text-[var(--text-error)] leading-tight'>
<p className='text-[11px] text-[var(--text-error)] leading-tight'>
{invitationError instanceof Error && invitationError.message
? invitationError.message
: String(invitationError)}

View File

@@ -104,7 +104,7 @@ export function NoOrganizationView({
<div className='flex flex-col gap-[8px]'>
{error && (
<p className='text-[12px] text-[var(--text-error)] leading-tight'>{error}</p>
<p className='text-[11px] text-[var(--text-error)] leading-tight'>{error}</p>
)}
<div className='flex justify-end'>
<Button
@@ -179,7 +179,7 @@ export function NoOrganizationView({
</div>
</div>
{error && <p className='text-[12px] text-[var(--text-error)] leading-tight'>{error}</p>}
{error && <p className='text-[11px] text-[var(--text-error)] leading-tight'>{error}</p>}
<ModalFooter>
<Button

View File

@@ -33,19 +33,13 @@ export function RemoveMemberDialog({
}: RemoveMemberDialogProps) {
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent size='sm'>
<ModalContent className='w-[400px]'>
<ModalHeader>{isSelfRemoval ? 'Leave Organization' : 'Remove Team Member'}</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
{isSelfRemoval ? (
'Are you sure you want to leave this organization? You will lose access to all team resources.'
) : (
<>
Are you sure you want to remove{' '}
<span className='font-medium text-[var(--text-primary)]'>{memberName}</span> from
the team?
</>
)}{' '}
{isSelfRemoval
? 'Are you sure you want to leave this organization? You will lose access to all team resources.'
: `Are you sure you want to remove ${memberName} from the team?`}{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
@@ -70,14 +64,14 @@ export function RemoveMemberDialog({
{error && (
<div className='mt-[8px]'>
<p className='text-[12px] text-[var(--text-error)] leading-tight'>
<p className='text-[11px] text-[var(--text-error)] leading-tight'>
{error instanceof Error && error.message ? error.message : String(error)}
</p>
</div>
)}
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={onCancel}>
<Button variant='active' onClick={onCancel}>
Cancel
</Button>
<Button variant='destructive' onClick={() => onConfirmRemove(shouldReduceSeats)}>

View File

@@ -7,9 +7,6 @@ import { useParams } from 'next/navigation'
import {
Badge,
Button,
ButtonGroup,
ButtonGroupItem,
Code,
Combobox,
type ComboboxOption,
Input as EmcnInput,
@@ -19,33 +16,22 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
SModalTabs,
SModalTabsBody,
SModalTabsContent,
SModalTabsList,
SModalTabsTrigger,
Textarea,
Tooltip,
} from '@/components/emcn'
import { Input, Skeleton } from '@/components/ui'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useApiKeys } from '@/hooks/queries/api-keys'
import {
useAddWorkflowMcpTool,
useCreateWorkflowMcpServer,
useDeleteWorkflowMcpServer,
useDeleteWorkflowMcpTool,
useDeployedWorkflows,
useUpdateWorkflowMcpServer,
useUpdateWorkflowMcpTool,
useWorkflowMcpServer,
useWorkflowMcpServers,
type WorkflowMcpServer,
type WorkflowMcpTool,
} from '@/hooks/queries/workflow-mcp-servers'
import { useWorkspaceSettings } from '@/hooks/queries/workspace'
import { CreateApiKeyModal } from '../api-keys/components'
import { FormField, McpServerSkeleton } from '../mcp/components'
const logger = createLogger('WorkflowMcpServers')
@@ -56,63 +42,22 @@ interface ServerDetailViewProps {
onBack: () => void
}
type McpClientType = 'cursor' | 'claude-code' | 'claude-desktop' | 'vscode'
function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewProps) {
const { data, isLoading, error } = useWorkflowMcpServer(workspaceId, serverId)
const { data, isLoading, error, refetch } = useWorkflowMcpServer(workspaceId, serverId)
const { data: deployedWorkflows = [], isLoading: isLoadingWorkflows } =
useDeployedWorkflows(workspaceId)
const deleteToolMutation = useDeleteWorkflowMcpTool()
const addToolMutation = useAddWorkflowMcpTool()
const updateToolMutation = useUpdateWorkflowMcpTool()
const updateServerMutation = useUpdateWorkflowMcpServer()
// API Keys - for "Create API key" link
const { data: apiKeysData } = useApiKeys(workspaceId)
const { data: workspaceSettingsData } = useWorkspaceSettings(workspaceId)
const userPermissions = useUserPermissionsContext()
const [showCreateApiKeyModal, setShowCreateApiKeyModal] = useState(false)
const existingKeyNames = [
...(apiKeysData?.workspaceKeys ?? []),
...(apiKeysData?.personalKeys ?? []),
].map((k) => k.name)
const allowPersonalApiKeys =
workspaceSettingsData?.settings?.workspace?.allowPersonalApiKeys ?? true
const canManageWorkspaceKeys = userPermissions.canAdmin
const defaultKeyType = allowPersonalApiKeys ? 'personal' : 'workspace'
const [copiedConfig, setCopiedConfig] = useState(false)
const [activeConfigTab, setActiveConfigTab] = useState<McpClientType>('cursor')
const [copiedUrl, setCopiedUrl] = useState(false)
const [toolToDelete, setToolToDelete] = useState<WorkflowMcpTool | null>(null)
const [toolToView, setToolToView] = useState<WorkflowMcpTool | null>(null)
const [editingDescription, setEditingDescription] = useState<string>('')
const [editingParameterDescriptions, setEditingParameterDescriptions] = useState<
Record<string, string>
>({})
const [showAddWorkflow, setShowAddWorkflow] = useState(false)
const [showEditServer, setShowEditServer] = useState(false)
const [editServerName, setEditServerName] = useState('')
const [editServerDescription, setEditServerDescription] = useState('')
const [editServerIsPublic, setEditServerIsPublic] = useState(false)
const [activeServerTab, setActiveServerTab] = useState<'workflows' | 'details'>('details')
useEffect(() => {
if (toolToView) {
setEditingDescription(toolToView.toolDescription || '')
const schema = toolToView.parameterSchema as
| { properties?: Record<string, { type?: string; description?: string }> }
| undefined
const properties = schema?.properties
if (properties) {
const descriptions: Record<string, string> = {}
for (const [name, prop] of Object.entries(properties)) {
descriptions[name] = prop.description || ''
}
setEditingParameterDescriptions(descriptions)
} else {
setEditingParameterDescriptions({})
}
}
}, [toolToView])
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null)
@@ -121,6 +66,12 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
return `${getBaseUrl()}/api/mcp/serve/${serverId}`
}, [serverId])
const handleCopyUrl = () => {
navigator.clipboard.writeText(mcpServerUrl)
setCopiedUrl(true)
setTimeout(() => setCopiedUrl(false), 2000)
}
const handleDeleteTool = async () => {
if (!toolToDelete) return
try {
@@ -145,7 +96,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
})
setShowAddWorkflow(false)
setSelectedWorkflowId(null)
setActiveServerTab('workflows')
refetch()
} catch (err) {
logger.error('Failed to add workflow:', err)
}
@@ -157,8 +108,6 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
const existingWorkflowIds = new Set(tools.map((t) => t.workflowId))
return deployedWorkflows.filter((w) => !existingWorkflowIds.has(w.id))
}, [deployedWorkflows, tools])
const canAddWorkflow = availableWorkflows.length > 0
const showAddDisabledTooltip = !canAddWorkflow && deployedWorkflows.length > 0
const workflowOptions: ComboboxOption[] = useMemo(() => {
return availableWorkflows.map((w) => ({
@@ -171,115 +120,6 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
return availableWorkflows.find((w) => w.id === selectedWorkflowId)
}, [availableWorkflows, selectedWorkflowId])
const getConfigSnippet = useCallback(
(client: McpClientType, isPublic: boolean, serverName: string): string => {
const safeName = serverName
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
if (client === 'claude-code') {
if (isPublic) {
return `claude mcp add "${safeName}" --url "${mcpServerUrl}"`
}
return `claude mcp add "${safeName}" --url "${mcpServerUrl}" --header "X-API-Key:$SIM_API_KEY"`
}
const mcpRemoteArgs = isPublic
? ['-y', 'mcp-remote', mcpServerUrl]
: ['-y', 'mcp-remote', mcpServerUrl, '--header', 'X-API-Key:$SIM_API_KEY']
const baseServerConfig = {
command: 'npx',
args: mcpRemoteArgs,
}
if (client === 'vscode') {
return JSON.stringify(
{
servers: {
[safeName]: {
type: 'stdio',
...baseServerConfig,
},
},
},
null,
2
)
}
return JSON.stringify(
{
mcpServers: {
[safeName]: baseServerConfig,
},
},
null,
2
)
},
[mcpServerUrl]
)
const handleCopyConfig = useCallback(
(isPublic: boolean, serverName: string) => {
const snippet = getConfigSnippet(activeConfigTab, isPublic, serverName)
navigator.clipboard.writeText(snippet)
setCopiedConfig(true)
setTimeout(() => setCopiedConfig(false), 2000)
},
[activeConfigTab, getConfigSnippet]
)
const handleOpenEditServer = useCallback(() => {
if (data?.server) {
setEditServerName(data.server.name)
setEditServerDescription(data.server.description || '')
setEditServerIsPublic(data.server.isPublic)
setShowEditServer(true)
}
}, [data?.server])
const handleSaveServerEdit = async () => {
if (!editServerName.trim()) return
try {
await updateServerMutation.mutateAsync({
workspaceId,
serverId,
name: editServerName.trim(),
description: editServerDescription.trim() || undefined,
isPublic: editServerIsPublic,
})
setShowEditServer(false)
} catch (err) {
logger.error('Failed to update server:', err)
}
}
const getCursorInstallUrl = useCallback(
(isPublic: boolean, serverName: string): string => {
const safeName = serverName
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
const config = isPublic
? {
command: 'npx',
args: ['-y', 'mcp-remote', mcpServerUrl],
}
: {
command: 'npx',
args: ['-y', 'mcp-remote', mcpServerUrl, '--header', 'X-API-Key:$SIM_API_KEY'],
}
const base64Config = btoa(JSON.stringify(config))
return `cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent(safeName)}&config=${encodeURIComponent(base64Config)}`
},
[mcpServerUrl]
)
if (isLoading) {
return (
<div className='flex h-full flex-col gap-[16px]'>
@@ -308,223 +148,97 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
return (
<>
<div className='flex h-full flex-col gap-[16px]'>
<SModalTabs
value={activeServerTab}
onValueChange={(value) => setActiveServerTab(value as 'workflows' | 'details')}
className='flex min-h-0 flex-1 flex-col'
>
<SModalTabsList activeValue={activeServerTab}>
<SModalTabsTrigger value='details'>Details</SModalTabsTrigger>
<SModalTabsTrigger value='workflows'>Workflows</SModalTabsTrigger>
</SModalTabsList>
<div className='min-h-0 flex-1 overflow-y-auto'>
<div className='flex flex-col gap-[16px]'>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Server Name
</span>
<p className='text-[14px] text-[var(--text-secondary)]'>{server.name}</p>
</div>
<SModalTabsBody>
<SModalTabsContent value='workflows'>
<div className='flex flex-col gap-[16px]'>
<div className='flex items-center justify-between'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Workflows
</span>
{showAddDisabledTooltip ? (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='inline-flex'>
<Button
variant='tertiary'
onClick={() => setShowAddWorkflow(true)}
disabled
>
<Plus className='mr-[6px] h-[13px] w-[13px]' />
Add
</Button>
</div>
</Tooltip.Trigger>
<Tooltip.Content>
All deployed workflows have been added to this server.
</Tooltip.Content>
</Tooltip.Root>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>Transport</span>
<p className='text-[14px] text-[var(--text-secondary)]'>Streamable-HTTP</p>
</div>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>URL</span>
<div className='flex items-center gap-[8px]'>
<p className='flex-1 break-all text-[14px] text-[var(--text-secondary)]'>
{mcpServerUrl}
</p>
<Button variant='ghost' onClick={handleCopyUrl} className='h-[32px] w-[32px] p-0'>
{copiedUrl ? (
<Check className='h-[14px] w-[14px]' />
) : (
<Button
variant='tertiary'
onClick={() => setShowAddWorkflow(true)}
disabled={!canAddWorkflow}
>
<Plus className='mr-[6px] h-[13px] w-[13px]' />
Add
</Button>
<Clipboard className='h-[14px] w-[14px]' />
)}
</div>
{tools.length === 0 ? (
<p className='text-[13px] text-[var(--text-muted)]'>
No workflows added yet. Click "Add" to add a deployed workflow.
</p>
) : (
<div className='flex flex-col gap-[8px]'>
{tools.map((tool) => (
<div key={tool.id} className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<span className='font-medium text-[14px]'>{tool.toolName}</span>
<p className='truncate text-[13px] text-[var(--text-muted)]'>
{tool.toolDescription || 'No description'}
</p>
</div>
<div className='flex flex-shrink-0 items-center gap-[4px]'>
<Button variant='default' onClick={() => setToolToView(tool)}>
Edit
</Button>
<Button
variant='ghost'
onClick={() => setToolToDelete(tool)}
disabled={deleteToolMutation.isPending}
>
Remove
</Button>
</div>
</div>
))}
</div>
)}
{deployedWorkflows.length === 0 && !isLoadingWorkflows && (
<p className='mt-[4px] text-[11px] text-[var(--text-muted)]'>
Deploy a workflow first to add it to this server.
</p>
)}
</div>
</SModalTabsContent>
<SModalTabsContent value='details'>
<div className='flex flex-col gap-[16px]'>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Server Name
</span>
<p className='text-[14px] text-[var(--text-secondary)]'>{server.name}</p>
</div>
{server.description?.trim() && (
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Description
</span>
<p className='text-[14px] text-[var(--text-secondary)]'>{server.description}</p>
</div>
)}
<div className='flex gap-[24px]'>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Transport
</span>
<p className='text-[14px] text-[var(--text-secondary)]'>Streamable-HTTP</p>
</div>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Access
</span>
<p className='text-[14px] text-[var(--text-secondary)]'>
{server.isPublic ? 'Public' : 'API Key'}
</p>
</div>
</div>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>URL</span>
<p className='break-all text-[14px] text-[var(--text-secondary)]'>
{mcpServerUrl}
</p>
</div>
<div>
<div className='mb-[6.5px] flex items-center justify-between'>
<span className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
MCP Client
</span>
</div>
<ButtonGroup
value={activeConfigTab}
onValueChange={(v) => setActiveConfigTab(v as McpClientType)}
>
<ButtonGroupItem value='cursor'>Cursor</ButtonGroupItem>
<ButtonGroupItem value='claude-code'>Claude Code</ButtonGroupItem>
<ButtonGroupItem value='claude-desktop'>Claude Desktop</ButtonGroupItem>
<ButtonGroupItem value='vscode'>VS Code</ButtonGroupItem>
</ButtonGroup>
</div>
<div>
<div className='mb-[6.5px] flex items-center justify-between'>
<span className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Configuration
</span>
<Button
variant='ghost'
onClick={() => handleCopyConfig(server.isPublic, server.name)}
className='!p-1.5 -my-1.5'
>
{copiedConfig ? (
<Check className='h-3 w-3' />
) : (
<Clipboard className='h-3 w-3' />
)}
</Button>
</div>
<div className='relative'>
<Code.Viewer
code={getConfigSnippet(activeConfigTab, server.isPublic, server.name)}
language={activeConfigTab === 'claude-code' ? 'javascript' : 'json'}
wrapText
className='!min-h-0 rounded-[4px] border border-[var(--border-1)]'
/>
{activeConfigTab === 'cursor' && (
<a
href={getCursorInstallUrl(server.isPublic, server.name)}
className='absolute top-[6px] right-2'
>
<img
src='https://cursor.com/deeplink/mcp-install-dark.svg'
alt='Add to Cursor'
className='h-[26px]'
/>
</a>
)}
</div>
{!server.isPublic && (
<p className='mt-[8px] text-[11px] text-[var(--text-muted)]'>
Replace $SIM_API_KEY with your API key, or{' '}
<button
type='button'
onClick={() => setShowCreateApiKeyModal(true)}
className='underline hover:text-[var(--text-secondary)]'
>
create one now
</button>
</p>
)}
</div>
</div>
</SModalTabsContent>
</SModalTabsBody>
</SModalTabs>
<div className='mt-auto flex items-center justify-between'>
<div className='flex items-center gap-[8px]'>
{activeServerTab === 'details' && (
<>
<Button onClick={handleOpenEditServer} variant='default'>
Edit Server
</Button>
</div>
</div>
<div className='flex flex-col gap-[8px]'>
<div className='flex items-center justify-between'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Workflows ({tools.length})
</span>
<Button
variant='tertiary'
onClick={() => setShowAddWorkflow(true)}
variant='default'
disabled={!canAddWorkflow}
disabled={availableWorkflows.length === 0}
>
Add Workflows
<Plus className='mr-[6px] h-[13px] w-[13px]' />
Add
</Button>
</>
)}
</div>
{tools.length === 0 ? (
<p className='text-[13px] text-[var(--text-muted)]'>
No workflows added yet. Click "Add" to add a deployed workflow.
</p>
) : (
<div className='flex flex-col gap-[8px]'>
{tools.map((tool) => (
<div key={tool.id} className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<span className='font-medium text-[14px]'>{tool.toolName}</span>
<p className='truncate text-[13px] text-[var(--text-muted)]'>
{tool.toolDescription || 'No description'}
</p>
</div>
<div className='flex flex-shrink-0 items-center gap-[4px]'>
<Button variant='default' onClick={() => setToolToView(tool)}>
Details
</Button>
<Button
variant='ghost'
onClick={() => setToolToDelete(tool)}
disabled={deleteToolMutation.isPending}
>
Remove
</Button>
</div>
</div>
))}
</div>
)}
{availableWorkflows.length === 0 && deployedWorkflows.length > 0 && (
<p className='mt-[4px] text-[11px] text-[var(--text-muted)]'>
All deployed workflows have been added to this server.
</p>
)}
{deployedWorkflows.length === 0 && !isLoadingWorkflows && (
<p className='mt-[4px] text-[11px] text-[var(--text-muted)]'>
Deploy a workflow first to add it to this server.
</p>
)}
</div>
</div>
</div>
<div className='mt-auto flex items-center justify-end'>
<Button onClick={onBack} variant='tertiary'>
Back
</Button>
@@ -532,7 +246,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
</div>
<Modal open={!!toolToDelete} onOpenChange={(open) => !open && setToolToDelete(null)}>
<ModalContent size='sm'>
<ModalContent className='w-[400px]'>
<ModalHeader>Remove Workflow</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
@@ -564,7 +278,6 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
if (!open) {
setToolToView(null)
setEditingDescription('')
setEditingParameterDescriptions({})
}
}}
>
@@ -572,10 +285,10 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
<ModalHeader>{toolToView?.toolName}</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-[16px]'>
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Description
</Label>
</span>
<Textarea
value={editingDescription}
onChange={(e) => setEditingDescription(e.target.value)}
@@ -584,58 +297,44 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
/>
</div>
{(() => {
const schema = toolToView?.parameterSchema as
| { properties?: Record<string, { type?: string; description?: string }> }
| undefined
const properties = schema?.properties
const hasParams = properties && Object.keys(properties).length > 0
return (
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Parameters
</Label>
{hasParams ? (
<div className='flex flex-col gap-[8px]'>
{Object.entries(properties).map(([name, prop]) => (
<div
key={name}
className='overflow-hidden rounded-[4px] border border-[var(--border-1)]'
>
<div className='flex items-center justify-between bg-[var(--surface-4)] px-[10px] py-[5px]'>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{name}
</span>
<Badge size='sm'>{prop.type || 'any'}</Badge>
</div>
</div>
<div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Description</Label>
<EmcnInput
value={editingParameterDescriptions[name] || ''}
onChange={(e) =>
setEditingParameterDescriptions((prev) => ({
...prev,
[name]: e.target.value,
}))
}
placeholder={`Enter description for ${name}`}
/>
</div>
</div>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Parameters
</span>
{(() => {
const schema = toolToView?.parameterSchema as
| { properties?: Record<string, { type?: string; description?: string }> }
| undefined
const properties = schema?.properties
if (!properties || Object.keys(properties).length === 0) {
return <p className='text-[13px] text-[var(--text-muted)]'>No parameters</p>
}
return (
<div className='flex flex-col gap-[8px]'>
{Object.entries(properties).map(([name, prop]) => (
<div
key={name}
className='rounded-[6px] border bg-[var(--surface-3)] px-[10px] py-[8px]'
>
<div className='flex items-center justify-between'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
{name}
</span>
<Badge variant='outline' size='sm'>
{prop.type || 'any'}
</Badge>
</div>
))}
</div>
) : (
<p className='text-[13px] text-[var(--text-muted)]'>
No inputs configured for this workflow.
</p>
)}
</div>
)
})()}
{prop.description && (
<p className='mt-[4px] text-[12px] text-[var(--text-muted)]'>
{prop.description}
</p>
)}
</div>
))}
</div>
)
})()}
</div>
</div>
</ModalBody>
<ModalFooter>
@@ -647,59 +346,23 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
onClick={async () => {
if (!toolToView) return
try {
const currentSchema = toolToView.parameterSchema as Record<string, unknown>
const currentProperties = (currentSchema?.properties || {}) as Record<
string,
{ type?: string; description?: string }
>
const updatedProperties: Record<string, { type?: string; description?: string }> =
{}
for (const [name, prop] of Object.entries(currentProperties)) {
updatedProperties[name] = {
...prop,
description: editingParameterDescriptions[name]?.trim() || undefined,
}
}
const updatedSchema = {
...currentSchema,
properties: updatedProperties,
}
await updateToolMutation.mutateAsync({
workspaceId,
serverId,
toolId: toolToView.id,
toolDescription: editingDescription.trim() || undefined,
parameterSchema: updatedSchema,
})
refetch()
setToolToView(null)
setEditingDescription('')
setEditingParameterDescriptions({})
} catch (err) {
logger.error('Failed to update tool:', err)
logger.error('Failed to update tool description:', err)
}
}}
disabled={(() => {
if (updateToolMutation.isPending) return true
if (!toolToView) return true
const descriptionChanged =
editingDescription.trim() !== (toolToView.toolDescription || '')
const schema = toolToView.parameterSchema as
| { properties?: Record<string, { type?: string; description?: string }> }
| undefined
const properties = schema?.properties || {}
const paramDescriptionsChanged = Object.keys(properties).some((name) => {
const original = properties[name]?.description || ''
const edited = editingParameterDescriptions[name]?.trim() || ''
return original !== edited
})
return !descriptionChanged && !paramDescriptionsChanged
})()}
disabled={
updateToolMutation.isPending ||
editingDescription.trim() === (toolToView?.toolDescription || '')
}
>
{updateToolMutation.isPending ? 'Saving...' : 'Save'}
</Button>
@@ -745,7 +408,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
}
/>
{addToolMutation.isError && (
<p className='text-[12px] text-[var(--text-error)] leading-tight'>
<p className='text-[11px] text-[var(--text-error)] leading-tight'>
{addToolMutation.error?.message || 'Failed to add workflow'}
</p>
)}
@@ -772,83 +435,6 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
</ModalFooter>
</ModalContent>
</Modal>
<Modal
open={showEditServer}
onOpenChange={(open) => {
if (!open) {
setShowEditServer(false)
}
}}
>
<ModalContent className='w-[420px]'>
<ModalHeader>Edit Server</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-[12px]'>
<FormField label='Server Name'>
<EmcnInput
placeholder='e.g., My MCP Server'
value={editServerName}
onChange={(e) => setEditServerName(e.target.value)}
className='h-9'
/>
</FormField>
<FormField label='Description'>
<Textarea
placeholder='Describe what this MCP server does (optional)'
value={editServerDescription}
onChange={(e) => setEditServerDescription(e.target.value)}
className='min-h-[60px] resize-none'
/>
</FormField>
<FormField label='Access'>
<ButtonGroup
value={editServerIsPublic ? 'public' : 'private'}
onValueChange={(value) => setEditServerIsPublic(value === 'public')}
>
<ButtonGroupItem value='private'>API Key</ButtonGroupItem>
<ButtonGroupItem value='public'>Public</ButtonGroupItem>
</ButtonGroup>
</FormField>
<p className='text-[11px] text-[var(--text-muted)]'>
{editServerIsPublic
? 'Anyone with the URL can call this server without authentication'
: 'Requests must include your Sim API key in the X-API-Key header'}
</p>
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowEditServer(false)}>
Cancel
</Button>
<Button
variant='tertiary'
onClick={handleSaveServerEdit}
disabled={
!editServerName.trim() ||
updateServerMutation.isPending ||
(editServerName === server.name &&
editServerDescription === (server.description || '') &&
editServerIsPublic === server.isPublic)
}
>
{updateServerMutation.isPending ? 'Saving...' : 'Save'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<CreateApiKeyModal
open={showCreateApiKeyModal}
onOpenChange={setShowCreateApiKeyModal}
workspaceId={workspaceId}
existingKeyNames={existingKeyNames}
allowPersonalApiKeys={allowPersonalApiKeys}
canManageWorkspaceKeys={canManageWorkspaceKeys}
defaultKeyType={defaultKeyType}
/>
</>
)
}
@@ -862,15 +448,12 @@ export function WorkflowMcpServers() {
const workspaceId = params.workspaceId as string
const { data: servers = [], isLoading, error } = useWorkflowMcpServers(workspaceId)
const { data: deployedWorkflows = [], isLoading: isLoadingWorkflows } =
useDeployedWorkflows(workspaceId)
const createServerMutation = useCreateWorkflowMcpServer()
const deleteServerMutation = useDeleteWorkflowMcpServer()
const [searchTerm, setSearchTerm] = useState('')
const [showAddForm, setShowAddForm] = useState(false)
const [formData, setFormData] = useState({ name: '', description: '', isPublic: false })
const [selectedWorkflowIds, setSelectedWorkflowIds] = useState<string[]>([])
const [formData, setFormData] = useState({ name: '' })
const [selectedServerId, setSelectedServerId] = useState<string | null>(null)
const [serverToDelete, setServerToDelete] = useState<WorkflowMcpServer | null>(null)
const [deletingServers, setDeletingServers] = useState<Set<string>>(new Set())
@@ -881,16 +464,8 @@ export function WorkflowMcpServers() {
return servers.filter((server) => server.name.toLowerCase().includes(search))
}, [servers, searchTerm])
const workflowOptions: ComboboxOption[] = useMemo(() => {
return deployedWorkflows.map((w) => ({
label: w.name,
value: w.id,
}))
}, [deployedWorkflows])
const resetForm = useCallback(() => {
setFormData({ name: '', description: '', isPublic: false })
setSelectedWorkflowIds([])
setFormData({ name: '' })
setShowAddForm(false)
}, [])
@@ -901,9 +476,6 @@ export function WorkflowMcpServers() {
await createServerMutation.mutateAsync({
workspaceId,
name: formData.name.trim(),
description: formData.description.trim() || undefined,
isPublic: formData.isPublic,
workflowIds: selectedWorkflowIds.length > 0 ? selectedWorkflowIds : undefined,
})
resetForm()
} catch (err) {
@@ -972,68 +544,17 @@ export function WorkflowMcpServers() {
{shouldShowForm && !isLoading && (
<div className='rounded-[8px] border p-[10px]'>
<div className='flex flex-col gap-[12px]'>
<div className='flex flex-col gap-[8px]'>
<FormField label='Server Name'>
<EmcnInput
placeholder='e.g., My MCP Server'
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
onChange={(e) => setFormData({ name: e.target.value })}
className='h-9'
/>
</FormField>
<FormField label='Description'>
<Textarea
placeholder='Describe what this MCP server does (optional)'
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className='min-h-[60px] resize-none'
/>
</FormField>
<FormField label='Workflows'>
<Combobox
options={workflowOptions}
multiSelect
multiSelectValues={selectedWorkflowIds}
onMultiSelectChange={setSelectedWorkflowIds}
placeholder='Select workflows...'
searchable
searchPlaceholder='Search workflows...'
isLoading={isLoadingWorkflows}
disabled={createServerMutation.isPending}
emptyMessage='No deployed workflows available'
overlayContent={
selectedWorkflowIds.length > 0 ? (
<span className='text-[var(--text-primary)]'>
{selectedWorkflowIds.length} workflow
{selectedWorkflowIds.length !== 1 ? 's' : ''} selected
</span>
) : undefined
}
/>
</FormField>
<FormField label='Access'>
<div className='flex items-center gap-[12px]'>
<ButtonGroup
value={formData.isPublic ? 'public' : 'private'}
onValueChange={(value) =>
setFormData({ ...formData, isPublic: value === 'public' })
}
>
<ButtonGroupItem value='private'>API Key</ButtonGroupItem>
<ButtonGroupItem value='public'>Public</ButtonGroupItem>
</ButtonGroup>
{formData.isPublic && (
<span className='text-[11px] text-[var(--text-muted)]'>
No authentication required
</span>
)}
</div>
</FormField>
<div className='flex items-center justify-end gap-[8px] pt-[4px]'>
<div className='flex items-center justify-end gap-[8px] pt-[12px]'>
<Button variant='ghost' onClick={resetForm}>
Cancel
</Button>
@@ -1066,7 +587,9 @@ export function WorkflowMcpServers() {
<div className='flex flex-col gap-[8px]'>
{filteredServers.map((server) => {
const count = server.toolCount || 0
const toolsLabel = `${count} tool${count !== 1 ? 's' : ''}`
const toolNames = server.toolNames || []
const names = count > 0 ? `: ${toolNames.join(', ')}` : ''
const toolsLabel = `${count} tool${count !== 1 ? 's' : ''}${names}`
const isDeleting = deletingServers.has(server.id)
return (
<div key={server.id} className='flex items-center justify-between gap-[12px]'>
@@ -1075,11 +598,9 @@ export function WorkflowMcpServers() {
<span className='max-w-[200px] truncate font-medium text-[14px]'>
{server.name}
</span>
{server.isPublic && (
<Badge variant='outline' size='sm'>
Public
</Badge>
)}
<span className='text-[13px] text-[var(--text-secondary)]'>
(Streamable-HTTP)
</span>
</div>
<p className='truncate text-[13px] text-[var(--text-muted)]'>{toolsLabel}</p>
</div>
@@ -1109,7 +630,7 @@ export function WorkflowMcpServers() {
</div>
<Modal open={!!serverToDelete} onOpenChange={(open) => !open && setServerToDelete(null)}>
<ModalContent size='sm'>
<ModalContent className='w-[400px]'>
<ModalHeader>Delete MCP Server</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>

View File

@@ -102,7 +102,7 @@ export function DeleteModal({
return (
<Modal open={isOpen} onOpenChange={onClose}>
<ModalContent size='sm'>
<ModalContent className='w-[400px]'>
<ModalHeader>{title}</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
@@ -111,7 +111,7 @@ export function DeleteModal({
</p>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={onClose} disabled={isDeleting}>
<Button variant='active' onClick={onClose} disabled={isDeleting}>
Cancel
</Button>
<Button variant='destructive' onClick={onConfirm} disabled={isDeleting}>

View File

@@ -607,7 +607,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
onOpenChange(newOpen)
}}
>
<ModalContent size='md'>
<ModalContent className='w-[500px]'>
<ModalHeader>Invite members to {workspaceName || 'Workspace'}</ModalHeader>
<form
@@ -740,7 +740,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
{/* Remove Member Confirmation Dialog */}
<Modal open={!!memberToRemove} onOpenChange={handleRemoveMemberCancel}>
<ModalContent size='sm'>
<ModalContent>
<ModalHeader>Remove Member</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
@@ -773,7 +773,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
{/* Remove Invitation Confirmation Dialog */}
<Modal open={!!invitationToRemove} onOpenChange={handleRemoveInvitationCancel}>
<ModalContent size='sm'>
<ModalContent className='w-[400px]'>
<ModalHeader>Cancel Invitation</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>

Some files were not shown because too many files have changed in this diff Show More