From a70f2a66905a29c3f374fb4fc5367e20b07f61e2 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com> Date: Thu, 13 Nov 2025 12:24:26 -0800 Subject: [PATCH 01/10] fix(executor): streaming after tool calls (#1963) * Provider changes * Fix lint --- apps/sim/executor/execution/block-executor.ts | 138 +++++++++++++++++- apps/sim/providers/anthropic/index.ts | 6 +- apps/sim/providers/azure-openai/index.ts | 6 +- apps/sim/providers/cerebras/index.ts | 4 +- apps/sim/providers/deepseek/index.ts | 4 +- apps/sim/providers/groq/index.ts | 4 +- apps/sim/providers/mistral/index.ts | 4 +- apps/sim/providers/ollama/index.ts | 4 +- apps/sim/providers/openai/index.ts | 6 +- apps/sim/providers/openrouter/index.ts | 2 +- apps/sim/providers/xai/index.ts | 4 +- 11 files changed, 155 insertions(+), 27 deletions(-) diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index d39387168b8..fd93d112611 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -21,6 +21,7 @@ import type { ExecutionContext, NormalizedBlockOutput, } from '@/executor/types' +import { streamingResponseFormatProcessor } from '@/executor/utils' import { buildBlockExecutionError, normalizeError } from '@/executor/utils/errors' import type { VariableResolver } from '@/executor/variables/resolver' import type { SerializedBlock } from '@/serializer/types' @@ -100,11 +101,14 @@ export class BlockExecutor { const streamingExec = output as { stream: ReadableStream; execution: any } if (ctx.onStream) { - try { - await ctx.onStream(streamingExec) - } catch (error) { - logger.error('Error in onStream callback', { blockId: node.id, error }) - } + await this.handleStreamingExecution( + ctx, + node, + block, + streamingExec, + resolvedInputs, + ctx.selectedOutputs ?? [] + ) } normalizedOutput = this.normalizeOutput( @@ -446,4 +450,128 @@ export class BlockExecutor { } } } + + private async handleStreamingExecution( + ctx: ExecutionContext, + node: DAGNode, + block: SerializedBlock, + streamingExec: { stream: ReadableStream; execution: any }, + resolvedInputs: Record, + selectedOutputs: string[] + ): Promise { + const blockId = node.id + + const responseFormat = + resolvedInputs?.responseFormat ?? + (block.config?.params as Record | undefined)?.responseFormat ?? + (block.config as Record | undefined)?.responseFormat + + const stream = streamingExec.stream + if (typeof stream.tee !== 'function') { + await this.forwardStream(ctx, blockId, streamingExec, stream, responseFormat, selectedOutputs) + return + } + + const [clientStream, executorStream] = stream.tee() + + const processedClientStream = streamingResponseFormatProcessor.processStream( + clientStream, + blockId, + selectedOutputs, + responseFormat + ) + + const clientStreamingExec = { + ...streamingExec, + stream: processedClientStream, + } + + const executorConsumption = this.consumeExecutorStream( + executorStream, + streamingExec, + blockId, + responseFormat + ) + + const clientConsumption = (async () => { + try { + await ctx.onStream?.(clientStreamingExec) + } catch (error) { + logger.error('Error in onStream callback', { blockId, error }) + } + })() + + await Promise.all([clientConsumption, executorConsumption]) + } + + private async forwardStream( + ctx: ExecutionContext, + blockId: string, + streamingExec: { stream: ReadableStream; execution: any }, + stream: ReadableStream, + responseFormat: any, + selectedOutputs: string[] + ): Promise { + const processedStream = streamingResponseFormatProcessor.processStream( + stream, + blockId, + selectedOutputs, + responseFormat + ) + + try { + await ctx.onStream?.({ + ...streamingExec, + stream: processedStream, + }) + } catch (error) { + logger.error('Error in onStream callback', { blockId, error }) + } + } + + private async consumeExecutorStream( + stream: ReadableStream, + streamingExec: { execution: any }, + blockId: string, + responseFormat: any + ): Promise { + const reader = stream.getReader() + const decoder = new TextDecoder() + let fullContent = '' + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + fullContent += decoder.decode(value, { stream: true }) + } + } catch (error) { + logger.error('Error reading executor stream for block', { blockId, error }) + } finally { + try { + reader.releaseLock() + } catch {} + } + + if (!fullContent) { + return + } + + const executionOutput = streamingExec.execution?.output + if (!executionOutput || typeof executionOutput !== 'object') { + return + } + + if (responseFormat) { + try { + const parsed = JSON.parse(fullContent.trim()) + Object.assign(executionOutput, parsed) + return + } catch (error) { + logger.warn('Failed to parse streamed content for response format', { blockId, error }) + } + } + + executionOutput.content = fullContent + } } diff --git a/apps/sim/providers/anthropic/index.ts b/apps/sim/providers/anthropic/index.ts index 80eb1344da3..8afa26446d1 100644 --- a/apps/sim/providers/anthropic/index.ts +++ b/apps/sim/providers/anthropic/index.ts @@ -985,9 +985,9 @@ ${fieldDescriptions} const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime - // After all tool processing complete, if streaming was requested and we have messages, use streaming for the final response - if (request.stream && iterationCount > 0) { - logger.info('Using streaming for final Anthropic response after tool calls') + // After all tool processing complete, if streaming was requested, use streaming for the final response + if (request.stream) { + logger.info('Using streaming for final Anthropic response after tool processing') // When streaming after tool calls with forced tools, make sure tool_choice is removed // This prevents the API from trying to force tool usage again in the final streaming response diff --git a/apps/sim/providers/azure-openai/index.ts b/apps/sim/providers/azure-openai/index.ts index 419c86dcacf..b4af62f63ac 100644 --- a/apps/sim/providers/azure-openai/index.ts +++ b/apps/sim/providers/azure-openai/index.ts @@ -523,9 +523,9 @@ export const azureOpenAIProvider: ProviderConfig = { iterationCount++ } - // After all tool processing complete, if streaming was requested and we have messages, use streaming for the final response - if (request.stream && iterationCount > 0) { - logger.info('Using streaming for final response after tool calls') + // After all tool processing complete, if streaming was requested, use streaming for the final response + if (request.stream) { + logger.info('Using streaming for final response after tool processing') // When streaming after tool calls with forced tools, make sure tool_choice is set to 'auto' // This prevents Azure OpenAI API from trying to force tool usage again in the final streaming response diff --git a/apps/sim/providers/cerebras/index.ts b/apps/sim/providers/cerebras/index.ts index f332a6e1df1..717d0babc16 100644 --- a/apps/sim/providers/cerebras/index.ts +++ b/apps/sim/providers/cerebras/index.ts @@ -455,8 +455,8 @@ export const cerebrasProvider: ProviderConfig = { const totalDuration = providerEndTime - providerStartTime // POST-TOOL-STREAMING: stream after tool calls if requested - if (request.stream && iterationCount > 0) { - logger.info('Using streaming for final Cerebras response after tool calls') + if (request.stream) { + logger.info('Using streaming for final Cerebras response after tool processing') // When streaming after tool calls with forced tools, make sure tool_choice is set to 'auto' // This prevents the API from trying to force tool usage again in the final streaming response diff --git a/apps/sim/providers/deepseek/index.ts b/apps/sim/providers/deepseek/index.ts index 1fc9f2af304..a303b70b650 100644 --- a/apps/sim/providers/deepseek/index.ts +++ b/apps/sim/providers/deepseek/index.ts @@ -457,8 +457,8 @@ export const deepseekProvider: ProviderConfig = { const totalDuration = providerEndTime - providerStartTime // POST-TOOL STREAMING: stream final response after tool calls if requested - if (request.stream && iterationCount > 0) { - logger.info('Using streaming for final DeepSeek response after tool calls') + if (request.stream) { + logger.info('Using streaming for final DeepSeek response after tool processing') // When streaming after tool calls with forced tools, make sure tool_choice is set to 'auto' // This prevents the API from trying to force tool usage again in the final streaming response diff --git a/apps/sim/providers/groq/index.ts b/apps/sim/providers/groq/index.ts index a986f5da200..d9ac569d210 100644 --- a/apps/sim/providers/groq/index.ts +++ b/apps/sim/providers/groq/index.ts @@ -374,8 +374,8 @@ export const groqProvider: ProviderConfig = { } // After all tool processing complete, if streaming was requested and we have messages, use streaming for the final response - if (request.stream && iterationCount > 0) { - logger.info('Using streaming for final Groq response after tool calls') + if (request.stream) { + logger.info('Using streaming for final Groq response after tool processing') // When streaming after tool calls with forced tools, make sure tool_choice is set to 'auto' // This prevents the API from trying to force tool usage again in the final streaming response diff --git a/apps/sim/providers/mistral/index.ts b/apps/sim/providers/mistral/index.ts index 6a9cb6dbe00..e2a194962fe 100644 --- a/apps/sim/providers/mistral/index.ts +++ b/apps/sim/providers/mistral/index.ts @@ -447,8 +447,8 @@ export const mistralProvider: ProviderConfig = { iterationCount++ } - if (request.stream && iterationCount > 0) { - logger.info('Using streaming for final response after tool calls') + if (request.stream) { + logger.info('Using streaming for final response after tool processing') const streamingPayload = { ...payload, diff --git a/apps/sim/providers/ollama/index.ts b/apps/sim/providers/ollama/index.ts index c529ce04200..21a50efac4f 100644 --- a/apps/sim/providers/ollama/index.ts +++ b/apps/sim/providers/ollama/index.ts @@ -529,8 +529,8 @@ export const ollamaProvider: ProviderConfig = { } // After all tool processing complete, if streaming was requested and we have messages, use streaming for the final response - if (request.stream && iterationCount > 0) { - logger.info('Using streaming for final response after tool calls') + if (request.stream) { + logger.info('Using streaming for final response after tool processing') const streamingPayload = { ...payload, diff --git a/apps/sim/providers/openai/index.ts b/apps/sim/providers/openai/index.ts index 5d33812b449..b925dc7d1f5 100644 --- a/apps/sim/providers/openai/index.ts +++ b/apps/sim/providers/openai/index.ts @@ -504,9 +504,9 @@ export const openaiProvider: ProviderConfig = { iterationCount++ } - // After all tool processing complete, if streaming was requested and we have messages, use streaming for the final response - if (request.stream && iterationCount > 0) { - logger.info('Using streaming for final response after tool calls') + // After all tool processing complete, if streaming was requested, use streaming for the final response + if (request.stream) { + logger.info('Using streaming for final response after tool processing') // When streaming after tool calls with forced tools, make sure tool_choice is set to 'auto' // This prevents OpenAI API from trying to force tool usage again in the final streaming response diff --git a/apps/sim/providers/openrouter/index.ts b/apps/sim/providers/openrouter/index.ts index 87a08fdb958..979b5783acc 100644 --- a/apps/sim/providers/openrouter/index.ts +++ b/apps/sim/providers/openrouter/index.ts @@ -381,7 +381,7 @@ export const openRouterProvider: ProviderConfig = { iterationCount++ } - if (request.stream && iterationCount > 0) { + if (request.stream) { const streamingPayload = { ...payload, messages: currentMessages, diff --git a/apps/sim/providers/xai/index.ts b/apps/sim/providers/xai/index.ts index a8d5c3a3f43..cfa73baf275 100644 --- a/apps/sim/providers/xai/index.ts +++ b/apps/sim/providers/xai/index.ts @@ -501,8 +501,8 @@ export const xAIProvider: ProviderConfig = { }) } - // After all tool processing complete, if streaming was requested and we have messages, use streaming for the final response - if (request.stream && iterationCount > 0) { + // After all tool processing complete, if streaming was requested, use streaming for the final response + if (request.stream) { // For final streaming response, choose between tools (auto) or response_format (never both) let finalStreamingPayload: any From 383b6f05a6c297d24fa33f6c2d8a884ce6fa73de Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 13 Nov 2025 12:31:10 -0800 Subject: [PATCH 02/10] fix(code): readd wand to code subblock (#1969) --- apps/sim/blocks/blocks/function.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sim/blocks/blocks/function.ts b/apps/sim/blocks/blocks/function.ts index 8f53efc8382..bdfc15ab11c 100644 --- a/apps/sim/blocks/blocks/function.ts +++ b/apps/sim/blocks/blocks/function.ts @@ -33,6 +33,7 @@ export const FunctionBlock: BlockConfig = { }, { id: 'code', + title: 'Code', type: 'code', wandConfig: { enabled: true, From 3a8f01f3e484bfd5684b97594ee70738bc161bd5 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 13 Nov 2025 13:59:09 -0800 Subject: [PATCH 03/10] fix(cmd-k): z-index + reoder tools, triggers (#1970) * fix(cmd-k): z-index + reoder tools, triggers * fix more z-index styling --- .../search-modal/search-modal.tsx | 40 ++++++++++++++----- .../components/subscription/subscription.tsx | 2 +- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/search-modal/search-modal.tsx index b8b87870277..c0fe05118de 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/search-modal/search-modal.tsx @@ -331,16 +331,35 @@ export function SearchModal({ return items }, [workspaces, workflows, pages, blocks, triggers, tools, docs]) - // Filter items based on search query - const filteredItems = useMemo(() => { - if (!searchQuery.trim()) return allItems + const sectionOrder = useMemo( + () => ['workspace', 'workflow', 'page', 'tool', 'trigger', 'block', 'doc'], + [] + ) - const query = searchQuery.toLowerCase() - return allItems.filter( - (item) => - item.name.toLowerCase().includes(query) || item.description?.toLowerCase().includes(query) + // Filter items based on search query and enforce section ordering + const filteredItems = useMemo(() => { + const orderMap = sectionOrder.reduce>( + (acc, type, index) => { + acc[type] = index + return acc + }, + {} as Record ) - }, [allItems, searchQuery]) + + const baseItems = !searchQuery.trim() + ? allItems + : allItems.filter( + (item) => + item.name.toLowerCase().includes(searchQuery.toLowerCase()) || + item.description?.toLowerCase().includes(searchQuery.toLowerCase()) + ) + + return [...baseItems].sort((a, b) => { + const aOrder = orderMap[a.type] ?? Number.MAX_SAFE_INTEGER + const bOrder = orderMap[b.type] ?? Number.MAX_SAFE_INTEGER + return aOrder - bOrder + }) + }, [allItems, searchQuery, sectionOrder]) // Reset selected index when filtered items change useEffect(() => { @@ -469,7 +488,7 @@ export function SearchModal({ @@ -493,7 +512,8 @@ export function SearchModal({ {/* Floating results container */} {filteredItems.length > 0 ? (
- {Object.entries(groupedItems).map(([type, items]) => { + {sectionOrder.map((type) => { + const items = groupedItems[type] || [] if (items.length === 0) return null return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx index 2e41a0956a9..d68bbe86924 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx @@ -670,7 +670,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { - + Workspace admins From 6f4f8cfad2d2374cac16947748bebbb9d53d616b Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com> Date: Thu, 13 Nov 2025 15:27:15 -0800 Subject: [PATCH 04/10] fix(executor): streaming response format (#1972) --- apps/sim/executor/execution/block-executor.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index fd93d112611..618f4822447 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -565,7 +565,15 @@ export class BlockExecutor { if (responseFormat) { try { const parsed = JSON.parse(fullContent.trim()) - Object.assign(executionOutput, parsed) + + streamingExec.execution.output = { + ...parsed, + tokens: executionOutput.tokens, + toolCalls: executionOutput.toolCalls, + providerTiming: executionOutput.providerTiming, + cost: executionOutput.cost, + model: executionOutput.model, + } return } catch (error) { logger.warn('Failed to parse streamed content for response format', { blockId, error }) From b67b4ff8fb63c0c3a0e61d7da19473fb4d93344c Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 13 Nov 2025 15:40:09 -0800 Subject: [PATCH 05/10] fix(workflow-block): fix redeploy header to not repeatedly show redeploy when redeploy is not necessary (#1973) * fix(workflow-block): fix redeploy header to not repeatedly show redeploy when redeploy is not necessary * cleanup --- .../hooks/use-child-deployment.ts | 59 ++++++++----------- apps/sim/components/emails/base-styles.ts | 6 +- .../emails/careers-confirmation-email.tsx | 4 +- .../emails/careers-submission-email.tsx | 8 +-- 4 files changed, 34 insertions(+), 43 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-child-deployment.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-child-deployment.ts index bdd0240708a..666e6705b28 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-child-deployment.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-child-deployment.ts @@ -35,19 +35,12 @@ export function useChildDeployment(childWorkflowId: string | undefined): UseChil try { setIsLoading(true) - // Fetch both deployment versions and workflow metadata in parallel - const [deploymentsRes, workflowRes] = await Promise.all([ - fetch(`/api/workflows/${wfId}/deployments`, { - cache: 'no-store', - headers: { 'Cache-Control': 'no-cache' }, - }), - fetch(`/api/workflows/${wfId}`, { - cache: 'no-store', - headers: { 'Cache-Control': 'no-cache' }, - }), - ]) + const statusRes = await fetch(`/api/workflows/${wfId}/status`, { + cache: 'no-store', + headers: { 'Cache-Control': 'no-cache' }, + }) - if (!deploymentsRes.ok || !workflowRes.ok) { + if (!statusRes.ok) { if (!cancelled) { setActiveVersion(null) setIsDeployed(null) @@ -56,32 +49,30 @@ export function useChildDeployment(childWorkflowId: string | undefined): UseChil return } - const deploymentsJson = await deploymentsRes.json() - const workflowJson = await workflowRes.json() + const statusData = await statusRes.json() - const versions = Array.isArray(deploymentsJson?.data?.versions) - ? deploymentsJson.data.versions - : Array.isArray(deploymentsJson?.versions) - ? deploymentsJson.versions - : [] + const deploymentsRes = await fetch(`/api/workflows/${wfId}/deployments`, { + cache: 'no-store', + headers: { 'Cache-Control': 'no-cache' }, + }) - const active = versions.find((v: any) => v.isActive) - const workflowUpdatedAt = workflowJson?.data?.updatedAt || workflowJson?.updatedAt + let activeVersion = null + if (deploymentsRes.ok) { + const deploymentsJson = await deploymentsRes.json() + const versions = Array.isArray(deploymentsJson?.data?.versions) + ? deploymentsJson.data.versions + : Array.isArray(deploymentsJson?.versions) + ? deploymentsJson.versions + : [] - if (!cancelled) { - const v = active ? Number(active.version) : null - const deployed = v != null - setActiveVersion(v) - setIsDeployed(deployed) + const active = versions.find((v: any) => v.isActive) + activeVersion = active ? Number(active.version) : null + } - // Check if workflow has been updated since deployment - if (deployed && active?.createdAt && workflowUpdatedAt) { - const deploymentTime = new Date(active.createdAt).getTime() - const updateTime = new Date(workflowUpdatedAt).getTime() - setNeedsRedeploy(updateTime > deploymentTime) - } else { - setNeedsRedeploy(false) - } + if (!cancelled) { + setActiveVersion(activeVersion) + setIsDeployed(statusData.isDeployed || false) + setNeedsRedeploy(statusData.needsRedeployment || false) } } catch { if (!cancelled) { diff --git a/apps/sim/components/emails/base-styles.ts b/apps/sim/components/emails/base-styles.ts index 4568984850c..633c762eee8 100644 --- a/apps/sim/components/emails/base-styles.ts +++ b/apps/sim/components/emails/base-styles.ts @@ -31,7 +31,7 @@ export const baseStyles = { }, button: { display: 'inline-block', - backgroundColor: '#802FFF', + backgroundColor: '#6F3DFA', color: '#ffffff', fontWeight: 'bold', fontSize: '16px', @@ -42,7 +42,7 @@ export const baseStyles = { margin: '20px 0', }, link: { - color: '#802FFF', + color: '#6F3DFA', textDecoration: 'underline', }, footer: { @@ -79,7 +79,7 @@ export const baseStyles = { width: '249px', }, sectionCenter: { - borderBottom: '1px solid #802FFF', + borderBottom: '1px solid #6F3DFA', width: '102px', }, } diff --git a/apps/sim/components/emails/careers-confirmation-email.tsx b/apps/sim/components/emails/careers-confirmation-email.tsx index 01905e38087..bd931d669f9 100644 --- a/apps/sim/components/emails/careers-confirmation-email.tsx +++ b/apps/sim/components/emails/careers-confirmation-email.tsx @@ -78,12 +78,12 @@ export const CareersConfirmationEmail = ({ href='https://docs.sim.ai' target='_blank' rel='noopener noreferrer' - style={{ color: '#802FFF', textDecoration: 'none' }} + style={{ color: '#6F3DFA', textDecoration: 'none' }} > documentation {' '} to learn more about what we're building, or check out our{' '} - + blog {' '} for the latest updates. diff --git a/apps/sim/components/emails/careers-submission-email.tsx b/apps/sim/components/emails/careers-submission-email.tsx index deb1f766e92..96246efbcdb 100644 --- a/apps/sim/components/emails/careers-submission-email.tsx +++ b/apps/sim/components/emails/careers-submission-email.tsx @@ -144,7 +144,7 @@ export const CareersSubmissionEmail = ({ {email} @@ -163,7 +163,7 @@ export const CareersSubmissionEmail = ({ Phone: - + {phone} @@ -231,7 +231,7 @@ export const CareersSubmissionEmail = ({ href={linkedin} target='_blank' rel='noopener noreferrer' - style={{ color: '#802FFF', textDecoration: 'none' }} + style={{ color: '#6F3DFA', textDecoration: 'none' }} > View Profile @@ -255,7 +255,7 @@ export const CareersSubmissionEmail = ({ href={portfolio} target='_blank' rel='noopener noreferrer' - style={{ color: '#802FFF', textDecoration: 'none' }} + style={{ color: '#6F3DFA', textDecoration: 'none' }} > View Portfolio From 80eaeb00c20e2343e5c743196f917c3ec24d7a62 Mon Sep 17 00:00:00 2001 From: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com> Date: Thu, 13 Nov 2025 16:02:13 -0800 Subject: [PATCH 06/10] improvement(platform): chat, emcn, terminal, usage-limit (#1974) * improvement(usage-indicator): layout * improvement: expand default terminal height * fix: swap workflow block ports * improvement: chat initial positioning * improvement(chat): display; improvement(emcn): popover attributes --- apps/sim/app/globals.css | 2 +- .../w/[workflowId]/components/chat/chat.tsx | 3 +- .../output-select/output-select.tsx | 47 ++++++++++--------- .../components/terminal/terminal.tsx | 2 +- .../workflow-block/workflow-block.tsx | 12 ++++- .../usage-indicator/usage-indicator.tsx | 25 +++++----- .../emcn/components/popover/popover.tsx | 40 ++++++++++++---- apps/sim/stores/chat/store.ts | 18 ++----- apps/sim/stores/terminal/store.ts | 2 +- 9 files changed, 91 insertions(+), 60 deletions(-) diff --git a/apps/sim/app/globals.css b/apps/sim/app/globals.css index c1dad3022b5..2df9fe5d17f 100644 --- a/apps/sim/app/globals.css +++ b/apps/sim/app/globals.css @@ -11,7 +11,7 @@ --panel-width: 244px; --toolbar-triggers-height: 300px; --editor-connections-height: 200px; - --terminal-height: 145px; + --terminal-height: 196px; } .sidebar-container { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index e41f9948ff3..7f2b800dc9f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -569,7 +569,7 @@ export function Chat() { return (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx index aade88c6c80..cfee42d06d2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx @@ -288,9 +288,11 @@ export function OutputSelect({ e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()} > @@ -298,26 +300,29 @@ export function OutputSelect({ {Object.entries(groupedOutputs).map(([blockName, outputs]) => (
{blockName} - {outputs.map((output) => ( - handleOutputSelection(output.label)} - > -
+ {outputs.map((output) => ( + handleOutputSelection(output.label)} > - - {blockName.charAt(0).toUpperCase()} - -
- {output.path} - {isSelectedValue(output) && } -
- ))} +
+ + {blockName.charAt(0).toUpperCase()} + +
+ {output.path} + {isSelectedValue(output) && } + + ))} +
))} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx index d7c2d1ebf44..c31db600364 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx @@ -42,7 +42,7 @@ import { useOutputPanelResize, useTerminalFilters, useTerminalResize } from './h */ const MIN_HEIGHT = 30 const NEAR_MIN_THRESHOLD = 40 -const DEFAULT_EXPANDED_HEIGHT = 300 +const DEFAULT_EXPANDED_HEIGHT = 196 /** * Column width constants - numeric values for calculations diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 5a94c74720b..c5a6f174d9f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -1,6 +1,6 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useParams } from 'next/navigation' -import { Handle, type NodeProps, Position } from 'reactflow' +import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow' import { Badge } from '@/components/emcn/components/badge/badge' import { Tooltip } from '@/components/emcn/components/tooltip/tooltip' import { getEnv, isTruthy } from '@/lib/env' @@ -689,6 +689,16 @@ export const WorkflowBlock = memo(function WorkflowBlock({ ], }) + /** + * Notify React Flow when handle orientation changes so it can recalculate edge paths. + * This is necessary because toggling handles doesn't change block dimensions, + * so useBlockDimensions won't trigger updateNodeInternals. + */ + const updateNodeInternals = useUpdateNodeInternals() + useEffect(() => { + updateNodeInternals(id) + }, [horizontalHandles, id, updateNodeInternals]) + const showWebhookIndicator = (isStarterBlock || isWebhookTriggerBlock) && isWebhookConfigured const shouldShowScheduleBadge = type === 'schedule' && !isLoadingScheduleInfo && scheduleInfo !== null diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx index 2ed700f9d64..8bd699217d2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx @@ -62,11 +62,14 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { if (isLoading) { return ( -
+
{/* Top row skeleton */}
- - +
+ + +
+
{/* Pills skeleton */} @@ -121,25 +124,25 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { } return ( -
+
{/* Top row */}
{PLAN_NAMES[planType]} -
+
{isBlocked ? ( <> - Over - limit + Over + limit ) : ( <> - + ${usage.current.toFixed(2)} - / - + / + ${usage.limit} @@ -149,7 +152,7 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { {showUpgradeButton && (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal.tsx index 01339cf8f8b..cdd09e88118 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal.tsx @@ -35,6 +35,7 @@ interface DeployedWorkflowModalProps { selectedVersionLabel?: string workflowId: string isSelectedVersionActive?: boolean + onLoadDeploymentComplete?: () => void } export function DeployedWorkflowModal({ @@ -49,6 +50,7 @@ export function DeployedWorkflowModal({ selectedVersionLabel, workflowId, isSelectedVersionActive, + onLoadDeploymentComplete, }: DeployedWorkflowModalProps) { const [showRevertDialog, setShowRevertDialog] = useState(false) const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) @@ -82,6 +84,7 @@ export function DeployedWorkflowModal({ setShowRevertDialog(false) onClose() + onLoadDeploymentComplete?.() } catch (error) { logger.error('Failed to revert workflow:', error) } @@ -91,7 +94,7 @@ export function DeployedWorkflowModal({
@@ -136,7 +139,7 @@ export function DeployedWorkflowModal({ - + Load this Deployment? diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx index 044be38aa4a..1e7947e12de 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx @@ -43,7 +43,6 @@ import { useDebounce } from '@/hooks/use-debounce' import { useFolderStore } from '@/stores/folders/store' import { useOperationQueueStore } from '@/stores/operation-queue/store' import { usePanelStore } from '@/stores/panel/store' -import { useSubscriptionStore } from '@/stores/subscription/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -81,11 +80,8 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { const { lastSaved, setNeedsRedeploymentFlag, blocks } = useWorkflowStore() const { workflows, - updateWorkflow, activeWorkflowId, - removeWorkflow, duplicateWorkflow, - setDeploymentStatus, isLoading: isRegistryLoading, } = useWorkflowRegistry() const { isExecuting, handleRunWorkflow, handleCancelExecution } = useWorkflowExecution() @@ -100,7 +96,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { useWorkflowExecution() // Local state - const [mounted, setMounted] = useState(false) + const [, setMounted] = useState(false) const [, forceUpdate] = useState({}) const [isExpanded, setIsExpanded] = useState(false) const [isWebhookSettingsOpen, setIsWebhookSettingsOpen] = useState(false) @@ -332,7 +328,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { /** * Check user usage limits and cache results */ - async function checkUserUsage(userId: string, forceRefresh = false): Promise { + async function checkUserUsage(_userId: string, forceRefresh = false): Promise { const now = Date.now() const cacheAge = now - usageDataCache.timestamp @@ -355,14 +351,8 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { return usage } - // Fallback: use store if API not available - const { getUsage, refresh } = useSubscriptionStore.getState() - if (forceRefresh) await refresh() - const usage = getUsage() - - // Update cache - usageDataCache = { data: usage, timestamp: now, expirationMs: usageDataCache.expirationMs } - return usage + // No fallback needed anymore - React Query handles this + return null } catch (error) { logger.error('Error checking usage limits:', { error }) return null @@ -1113,6 +1103,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { + {getTooltipContent()} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/combobox/combobox.tsx index b3096588a79..54ddbb347a9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -1,8 +1,6 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useParams } from 'next/navigation' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useReactFlow } from 'reactflow' import { Combobox, type ComboboxOption } from '@/components/emcn/components' -import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text' import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/sub-block-input-controller' @@ -10,19 +8,14 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import type { SubBlockConfig } from '@/blocks/types' -const logger = createLogger('ComboBox') - /** * Constants for ComboBox component behavior */ -const CURSOR_POSITION_DELAY = 0 -const SCROLL_SYNC_DELAY = 0 const DEFAULT_MODEL = 'gpt-4o' const ZOOM_FACTOR_BASE = 0.96 const MIN_ZOOM = 0.1 const MAX_ZOOM = 1 const ZOOM_DURATION = 0 -const DROPDOWN_CLOSE_DELAY = 150 /** * Represents a selectable option in the combobox @@ -57,17 +50,6 @@ interface ComboBoxProps { config: SubBlockConfig } -/** - * ComboBox component that provides a searchable dropdown with support for: - * - Free text input or selection from predefined options - * - Environment variable and tag insertion via special triggers - * - Drag and drop connections from other blocks - * - Keyboard navigation (Arrow keys, Enter, Escape) - * - Preview mode for displaying read-only values - * - * @param props - Component props - * @returns Rendered ComboBox component - */ export function ComboBox({ options, defaultValue, @@ -81,20 +63,12 @@ export function ComboBox({ config, }: ComboBoxProps) { // Hooks and context - const params = useParams() const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) const accessiblePrefixes = useAccessibleReferencePrefixes(blockId) const reactFlowInstance = useReactFlow() // State management const [storeInitialized, setStoreInitialized] = useState(false) - const [open, setOpen] = useState(false) - const [highlightedIndex, setHighlightedIndex] = useState(-1) - - // Refs - const inputRef = useRef(null) - const overlayRef = useRef(null) - const dropdownRef = useRef(null) // Determine the active value based on mode (preview vs. controlled vs. store) const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue @@ -123,15 +97,6 @@ export function ComboBox({ return typeof option === 'string' ? option : option.id }, []) - /** - * Extracts the display label from an option - * @param option - The option to extract label from - * @returns The option's display label - */ - const getOptionLabel = useCallback((option: ComboBoxOption): string => { - return typeof option === 'string' ? option : option.label - }, []) - /** * Determines the default option value to use. * Priority: explicit defaultValue > gpt-4o for model field > first option @@ -157,33 +122,27 @@ export function ComboBox({ }, [defaultValue, evaluatedOptions, subBlockId, getOptionValue]) /** - * Filters options based on current input value - * Shows all options when dropdown is closed or when value matches an exact option - * Otherwise filters by search term + * Resolve the user-facing text for the current stored value. + * - For object options, map stored ID -> label + * - For everything else, display the raw value */ - const filteredOptions = useMemo(() => { - // Always show all options when dropdown is not open - if (!open) return evaluatedOptions - - // If no value or value matches an exact option, show all options - if (!value) return evaluatedOptions + const displayValue = useMemo(() => { + const raw = value?.toString() ?? '' + if (!raw) return '' - const currentValue = value.toString() - const exactMatch = evaluatedOptions.find( - (opt) => getOptionValue(opt) === currentValue || getOptionLabel(opt) === currentValue + const match = evaluatedOptions.find((option) => + typeof option === 'string' ? option === raw : option.id === raw ) - // If current value exactly matches an option, show all options (user just selected it) - if (exactMatch) return evaluatedOptions + if (!match) return raw + return typeof match === 'string' ? match : match.label + }, [value, evaluatedOptions]) - // Otherwise filter based on current input - return evaluatedOptions.filter((option) => { - const label = getOptionLabel(option).toLowerCase() - const optionValue = getOptionValue(option).toLowerCase() - const search = currentValue.toLowerCase() - return label.includes(search) || optionValue.includes(search) - }) - }, [evaluatedOptions, value, open, getOptionValue, getOptionLabel]) + const [inputValue, setInputValue] = useState(displayValue) + + useEffect(() => { + setInputValue(displayValue) + }, [displayValue]) // Mark store as initialized on first render useEffect(() => { @@ -201,128 +160,6 @@ export function ComboBox({ } }, [storeInitialized, value, defaultOptionValue, setStoreValue]) - /** - * Handles selection of an option from the dropdown - * @param selectedValue - The value of the selected option - */ - const handleSelect = useCallback( - (selectedValue: string) => { - if (!isPreview && !disabled) { - setStoreValue(selectedValue) - } - setOpen(false) - setHighlightedIndex(-1) - inputRef.current?.blur() - }, - [isPreview, disabled, setStoreValue] - ) - - /** - * Handles click on the dropdown chevron button - * @param e - Mouse event - */ - const handleDropdownClick = useCallback( - (e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() - if (!disabled) { - setOpen((prev) => { - const newOpen = !prev - if (newOpen) { - inputRef.current?.focus() - } - return newOpen - }) - } - }, - [disabled] - ) - - /** - * Handles focus event on the input - */ - const handleFocus = useCallback(() => { - setOpen(true) - setHighlightedIndex(-1) - }, []) - - /** - * Handles blur event on the input - * Delays closing to allow for dropdown interactions - */ - const handleBlur = useCallback(() => { - // Delay closing to allow dropdown selection - setTimeout(() => { - const activeElement = document.activeElement - if (!activeElement || !activeElement.closest('.absolute.top-full')) { - setOpen(false) - setHighlightedIndex(-1) - } - }, DROPDOWN_CLOSE_DELAY) - }, []) - - /** - * Handles keyboard navigation and selection - * @param e - Keyboard event - */ - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Escape') { - setOpen(false) - setHighlightedIndex(-1) - return - } - - if (e.key === 'ArrowDown') { - e.preventDefault() - if (!open) { - setOpen(true) - setHighlightedIndex(0) - } else { - setHighlightedIndex((prev) => (prev < filteredOptions.length - 1 ? prev + 1 : 0)) - } - } - - if (e.key === 'ArrowUp') { - e.preventDefault() - if (open) { - setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filteredOptions.length - 1)) - } - } - - if (e.key === 'Enter' && open && highlightedIndex >= 0) { - e.preventDefault() - const selectedOption = filteredOptions[highlightedIndex] - if (selectedOption) { - handleSelect(getOptionValue(selectedOption)) - } - } - }, - [open, filteredOptions, highlightedIndex, handleSelect, getOptionValue] - ) - - /** - * Synchronizes overlay scroll with input scroll - * @param e - UI event from input element - */ - const handleScroll = useCallback((e: React.UIEvent) => { - if (overlayRef.current) { - overlayRef.current.scrollLeft = e.currentTarget.scrollLeft - } - }, []) - - /** - * Synchronizes overlay scroll after paste operation - * @param e - Clipboard event - */ - const handlePaste = useCallback((e: React.ClipboardEvent) => { - setTimeout(() => { - if (inputRef.current && overlayRef.current) { - overlayRef.current.scrollLeft = inputRef.current.scrollLeft - } - }, SCROLL_SYNC_DELAY) - }, []) - /** * Handles wheel event for ReactFlow zoom control * Intercepts Ctrl/Cmd+Wheel to zoom the canvas @@ -362,121 +199,34 @@ export function ComboBox({ [reactFlowInstance] ) - // Synchronize overlay scroll position with input when value changes - useEffect(() => { - if (inputRef.current && overlayRef.current) { - overlayRef.current.scrollLeft = inputRef.current.scrollLeft - } - }, [value]) - - // Adjust highlighted index when filtered options change - useEffect(() => { - setHighlightedIndex((prev) => { - if (prev >= 0 && prev < filteredOptions.length) { - return prev - } - return -1 - }) - }, [filteredOptions]) - - // Scroll highlighted option into view for keyboard navigation - useEffect(() => { - if (highlightedIndex >= 0 && dropdownRef.current) { - const highlightedElement = dropdownRef.current.querySelector( - `[data-option-index="${highlightedIndex}"]` - ) - if (highlightedElement) { - highlightedElement.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - }) - } - } - }, [highlightedIndex]) - - // Handle clicks outside the dropdown to close it - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - const target = event.target as Element - if ( - inputRef.current && - !inputRef.current.contains(target) && - !target.closest('[data-radix-popper-content-wrapper]') && - !target.closest('.absolute.top-full') - ) { - setOpen(false) - setHighlightedIndex(-1) - } - } - - if (open) { - document.addEventListener('mousedown', handleClickOutside) - return () => { - document.removeEventListener('mousedown', handleClickOutside) - } - } - }, [open]) - - const displayValue = useMemo(() => value?.toString() ?? '', [value]) - - /** - * Handles value change from Combobox - */ - const handleComboboxChange = useCallback( - (newValue: string) => { - if (!isPreview) { - setStoreValue(newValue) - } - }, - [isPreview, setStoreValue] - ) - /** * Gets the icon for the currently selected option */ - const selectedOptionIcon = useMemo(() => { - const selectedOpt = comboboxOptions.find((opt) => opt.value === displayValue) - return selectedOpt?.icon - }, [comboboxOptions, displayValue]) + const selectedOption = useMemo(() => { + if (!value) return undefined + return comboboxOptions.find((opt) => opt.value === value) + }, [comboboxOptions, value]) + + const selectedOptionIcon = selectedOption?.icon /** * Overlay content for the editable combobox */ const overlayContent = useMemo(() => { const SelectedIcon = selectedOptionIcon + const displayLabel = inputValue return (
{SelectedIcon && }
- {formatDisplayText(displayValue, { + {formatDisplayText(displayLabel, { accessiblePrefixes, highlightAll: !accessiblePrefixes, })}
) - }, [displayValue, accessiblePrefixes, selectedOptionIcon]) - - /** - * Handles mouse enter on dropdown option - * @param index - Index of the option - */ - const handleOptionMouseEnter = useCallback((index: number) => { - setHighlightedIndex(index) - }, []) - - /** - * Handles mouse down on dropdown option - * @param e - Mouse event - * @param optionValue - Value of the selected option - */ - const handleOptionMouseDown = useCallback( - (e: React.MouseEvent, optionValue: string) => { - e.preventDefault() - handleSelect(optionValue) - }, - [handleSelect] - ) + }, [inputValue, accessiblePrefixes, selectedOption, selectedOptionIcon]) return (
@@ -486,9 +236,23 @@ export function ComboBox({ config={config} value={propValue} onChange={(newValue) => { - if (!isPreview) { - setStoreValue(newValue) + if (isPreview) { + return + } + + const matchedOption = evaluatedOptions.find((option) => { + if (typeof option === 'string') { + return option === newValue + } + return option.id === newValue + }) + + if (!matchedOption) { + return } + + const nextValue = typeof matchedOption === 'string' ? matchedOption : matchedOption.id + setStoreValue(nextValue) }} isPreview={isPreview} disabled={disabled} @@ -497,9 +261,19 @@ export function ComboBox({ {({ ref, onChange: ctrlOnChange, onDrop, onDragOver }) => ( { - // Use controller's handler for consistency + const matchedComboboxOption = comboboxOptions.find( + (option) => option.value === newValue + ) + if (matchedComboboxOption) { + setInputValue(matchedComboboxOption.label) + } else { + setInputValue(newValue) + } + + // Use controller's handler so env vars, tags, and DnD still work const syntheticEvent = { target: { value: newValue, selectionStart: newValue.length }, } as React.ChangeEvent @@ -515,8 +289,6 @@ export function ComboBox({ inputProps={{ onDrop: onDrop as (e: React.DragEvent) => void, onDragOver: onDragOver as (e: React.DragEvent) => void, - onScroll: handleScroll, - onPaste: handlePaste, onWheel: handleWheel, autoComplete: 'off', }} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/env-var-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/env-var-dropdown.tsx index ab762b7886d..0fee1b96765 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/env-var-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/env-var-dropdown.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' +import { Plus } from 'lucide-react' import { Popover, PopoverAnchor, @@ -8,7 +9,11 @@ import { PopoverSection, } from '@/components/emcn' import { cn } from '@/lib/utils' -import { useEnvironmentStore } from '@/stores/settings/environment/store' +import { + usePersonalEnvironment, + useWorkspaceEnvironment, + type WorkspaceEnvironmentData, +} from '@/hooks/queries/environment' /** * Props for the EnvVarDropdown component @@ -113,28 +118,27 @@ export const EnvVarDropdown: React.FC = ({ maxHeight = 'none', inputRef, }) => { - const loadWorkspaceEnvironment = useEnvironmentStore((state) => state.loadWorkspaceEnvironment) - const userEnvVars = useEnvironmentStore((state) => Object.keys(state.variables)) - const [workspaceEnvData, setWorkspaceEnvData] = useState<{ - workspace: Record - personal: Record - conflicts: string[] - }>({ workspace: {}, personal: {}, conflicts: [] }) + // React Query hooks for environment variables + const { data: personalEnv = {} } = usePersonalEnvironment() + const { data: workspaceEnvData } = useWorkspaceEnvironment(workspaceId || '', { + select: useCallback( + (data: WorkspaceEnvironmentData): WorkspaceEnvironmentData => ({ + workspace: data.workspace || {}, + personal: data.personal || {}, + conflicts: data.conflicts || [], + }), + [] + ), + }) + + const userEnvVars = Object.keys(personalEnv) const [selectedIndex, setSelectedIndex] = useState(0) - useEffect(() => { - if (workspaceId && visible) { - loadWorkspaceEnvironment(workspaceId).then((data) => { - setWorkspaceEnvData(data) - }) - } - }, [workspaceId, visible, loadWorkspaceEnvironment]) - const envVarGroups: EnvVarGroup[] = [] - if (workspaceId) { - const workspaceVars = Object.keys(workspaceEnvData.workspace) - const personalVars = Object.keys(workspaceEnvData.personal) + if (workspaceId && workspaceEnvData) { + const workspaceVars = Object.keys(workspaceEnvData?.workspace || {}) + const personalVars = Object.keys(workspaceEnvData?.personal || {}) envVarGroups.push({ label: 'Workspace', variables: workspaceVars }) envVarGroups.push({ label: 'Personal', variables: personalVars }) @@ -163,6 +167,11 @@ export const EnvVarDropdown: React.FC = ({ setSelectedIndex(0) }, [searchTerm]) + const openEnvironmentSettings = () => { + window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'environment' } })) + onClose?.() + } + const handleEnvVarSelect = (envVar: string) => { const textBeforeCursor = inputValue.slice(0, cursorPosition) const textAfterCursor = inputValue.slice(cursorPosition) @@ -284,9 +293,17 @@ export const EnvVarDropdown: React.FC = ({ onCloseAutoFocus={(e) => e.preventDefault()} > {filteredEnvVars.length === 0 ? ( -
- No matching environment variables -
+ + { + e.preventDefault() + openEnvironmentSettings() + }} + > + + Create environment variable + + ) : ( {filteredGroups.map((group) => ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx index 03435222d68..67d955c149b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { useState } from 'react' import { Check, ChevronDown, RefreshCw } from 'lucide-react' import { useParams } from 'next/navigation' import { Button } from '@/components/ui/button' @@ -15,7 +15,7 @@ import { import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value' import type { SubBlockConfig } from '@/blocks/types' -import { useEnabledServers, useMcpServersStore } from '@/stores/mcp-servers/store' +import { useMcpServers } from '@/hooks/queries/mcp' interface McpServerSelectorProps { blockId: string @@ -36,8 +36,8 @@ export function McpServerSelector({ const workspaceId = params.workspaceId as string const [open, setOpen] = useState(false) - const { fetchServers, isLoading, error } = useMcpServersStore() - const enabledServers = useEnabledServers() + const { data: servers = [], isLoading, error } = useMcpServers(workspaceId) + const enabledServers = servers.filter((s) => s.enabled && !s.deletedAt) const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) @@ -48,15 +48,9 @@ export function McpServerSelector({ const selectedServer = enabledServers.find((server) => server.id === selectedServerId) - useEffect(() => { - fetchServers(workspaceId) - }, [fetchServers, workspaceId]) - const handleOpenChange = (isOpen: boolean) => { setOpen(isOpen) - if (isOpen) { - fetchServers(workspaceId) - } + // React Query automatically keeps server list fresh } const handleSelect = (serverId: string) => { @@ -102,7 +96,9 @@ export function McpServerSelector({ ) : error ? (

Error loading servers

-

{error}

+

+ {error instanceof Error ? error.message : 'Unknown error'} +

) : (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx index 865bd74aeb5..7044f54b25d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx @@ -36,7 +36,12 @@ import { import { CodeEditor } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor' import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar' import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand' -import { useCustomToolsStore } from '@/stores/custom-tools/store' +import { + useCreateCustomTool, + useCustomTools, + useDeleteCustomTool, + useUpdateCustomTool, +} from '@/hooks/queries/custom-tools' const logger = createLogger('CustomToolModal') @@ -261,9 +266,11 @@ try { // Schema params keyboard navigation const [schemaParamSelectedIndex, setSchemaParamSelectedIndex] = useState(0) - const createTool = useCustomToolsStore((state) => state.createTool) - const updateTool = useCustomToolsStore((state) => state.updateTool) - const deleteTool = useCustomToolsStore((state) => state.deleteTool) + // React Query mutations + const createToolMutation = useCreateCustomTool() + const updateToolMutation = useUpdateCustomTool() + const deleteToolMutation = useDeleteCustomTool() + const { data: customTools = [] } = useCustomTools(workspaceId) // Initialize form with initial values if provided useEffect(() => { @@ -448,10 +455,8 @@ try { if (isEditing && !toolIdToUpdate && initialValues?.schema) { const originalName = initialValues.schema.function?.name if (originalName) { - const customToolsStore = useCustomToolsStore.getState() - const existingTools = customToolsStore.getAllTools() - const originalTool = existingTools.find( - (tool) => tool.schema.function.name === originalName + const originalTool = customTools.find( + (tool) => tool.schema?.function?.name === originalName ) if (originalTool) { toolIdToUpdate = originalTool.id @@ -460,23 +465,27 @@ try { } // Save to the store (server validates duplicates) - let _finalToolId: string | undefined = toolIdToUpdate - if (isEditing && toolIdToUpdate) { // Update existing tool - await updateTool(workspaceId, toolIdToUpdate, { - title: name, - schema, - code: functionCode || '', + await updateToolMutation.mutateAsync({ + workspaceId, + toolId: toolIdToUpdate, + updates: { + title: name, + schema, + code: functionCode || '', + }, }) } else { // Create new tool - const createdTool = await createTool(workspaceId, { - title: name, - schema, - code: functionCode || '', + await createToolMutation.mutateAsync({ + workspaceId, + tool: { + title: name, + schema, + code: functionCode || '', + }, }) - _finalToolId = createdTool.id } // Create the custom tool object for the parent component @@ -782,8 +791,11 @@ try { try { setShowDeleteConfirm(false) - // Delete from store (which calls the API) - await deleteTool(workspaceId, toolId) + // Delete using React Query mutation + await deleteToolMutation.mutateAsync({ + workspaceId, + toolId, + }) logger.info(`Deleted tool: ${toolId}`) // Notify parent component if callback provided @@ -966,11 +978,6 @@ try { language='json' showWandButton={true} onWandClick={() => { - logger.debug('Schema AI button clicked') - logger.debug( - 'showPromptInline function exists:', - typeof schemaGeneration.showPromptInline === 'function' - ) schemaGeneration.isPromptVisible ? schemaGeneration.hidePromptInline() : schemaGeneration.showPromptInline() @@ -1045,11 +1052,6 @@ try { language='javascript' showWandButton={true} onWandClick={() => { - logger.debug('Code AI button clicked') - logger.debug( - 'showPromptInline function exists:', - typeof codeGeneration.showPromptInline === 'function' - ) codeGeneration.isPromptVisible ? codeGeneration.hidePromptInline() : codeGeneration.showPromptInline() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/mcp-server-modal/mcp-server-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/mcp-server-modal/mcp-server-modal.tsx index b750287b114..bb192d4d4ed 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/mcp-server-modal/mcp-server-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/mcp-server-modal/mcp-server-modal.tsx @@ -28,8 +28,8 @@ import { } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/env-var-dropdown' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' +import { useCreateMcpServer } from '@/hooks/queries/mcp' import { useMcpServerTest } from '@/hooks/use-mcp-server-test' -import { useMcpServersStore } from '@/stores/mcp-servers/store' const logger = createLogger('McpServerModal') @@ -61,7 +61,7 @@ export function McpServerModal({ url: '', headers: { '': '' }, }) - const { createServer, isLoading, error: storeError, clearError } = useMcpServersStore() + const createServerMutation = useCreateMcpServer() const [localError, setLocalError] = useState(null) // MCP server testing @@ -79,7 +79,7 @@ export function McpServerModal({ const [urlScrollLeft, setUrlScrollLeft] = useState(0) const [headerScrollLeft, setHeaderScrollLeft] = useState>({}) - const error = localError || storeError + const error = localError || createServerMutation.error?.message const resetForm = () => { setFormData({ @@ -89,7 +89,7 @@ export function McpServerModal({ headers: { '': '' }, }) setLocalError(null) - clearError() + createServerMutation.reset() setShowEnvVars(false) setActiveInputField(null) setActiveHeaderIndex(null) @@ -210,7 +210,7 @@ export function McpServerModal({ } setLocalError(null) - clearError() + createServerMutation.reset() try { // If no test has been done, test first @@ -242,13 +242,16 @@ export function McpServerModal({ ) ) - await createServer(workspaceId, { - name: formData.name.trim(), - transport: formData.transport, - url: formData.url, - timeout: 30000, - headers: cleanHeaders, - enabled: true, + await createServerMutation.mutateAsync({ + workspaceId, + config: { + name: formData.name.trim(), + transport: formData.transport, + url: formData.url, + timeout: 30000, + headers: cleanHeaders, + enabled: true, + }, }) logger.info(`Added MCP server: ${formData.name}`) @@ -267,8 +270,7 @@ export function McpServerModal({ testConnection, onOpenChange, onServerCreated, - createServer, - clearError, + createServerMutation, workspaceId, ]) @@ -563,16 +565,18 @@ export function McpServerModal({ resetForm() onOpenChange(false) }} - disabled={isLoading} + disabled={createServerMutation.isPending} > Cancel
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/tool-input.tsx index 2b5f0f7d73d..ffd21cfcb2d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -50,9 +50,9 @@ import { ToolCommand } from '@/app/workspace/[workspaceId]/w/[workflowId]/compon import { ToolCredentialSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value' import { getAllBlocks } from '@/blocks' +import { useCustomTools } from '@/hooks/queries/custom-tools' import { useMcpTools } from '@/hooks/use-mcp-tools' import { getProviderFromModel, supportsToolUsageControl } from '@/providers/utils' -import { useCustomToolsStore } from '@/stores/custom-tools/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { formatParameterLabel, @@ -476,8 +476,7 @@ export function ToolInput({ const [searchQuery, setSearchQuery] = useState('') const [draggedIndex, setDraggedIndex] = useState(null) const [dragOverIndex, setDragOverIndex] = useState(null) - const customTools = useCustomToolsStore((state) => state.getAllTools()) - const fetchCustomTools = useCustomToolsStore((state) => state.fetchTools) + const { data: customTools = [] } = useCustomTools(workspaceId) const subBlockStore = useSubBlockStore() // MCP tools integration @@ -488,13 +487,6 @@ export function ToolInput({ refreshTools, } = useMcpTools(workspaceId) - // Fetch custom tools on mount - useEffect(() => { - if (workspaceId) { - fetchCustomTools(workspaceId) - } - }, [workspaceId, fetchCustomTools]) - // Get the current model from the 'model' subblock const modelValue = useSubBlockStore.getState().getValue(blockId, 'model') const model = typeof modelValue === 'string' ? modelValue : '' @@ -706,7 +698,7 @@ export function ToolInput({ (customTool: CustomTool) => { if (isPreview || disabled) return - const customToolId = `custom-${customTool.schema.function.name}` + const customToolId = `custom-${customTool.schema?.function?.name || 'unknown'}` const newTool: StoredTool = { type: 'custom-tool', @@ -782,7 +774,7 @@ export function ToolInput({ customTools.some( (customTool) => customTool.id === toolId && - customTool.schema.function.name === tool.schema.function.name + customTool.schema?.function?.name === tool.schema.function.name ) ) { return false @@ -823,7 +815,6 @@ export function ToolInput({ const handleOperationChange = useCallback( (toolIndex: number, operation: string) => { if (isPreview || disabled) { - logger.info('❌ Early return: preview or disabled') return } @@ -832,7 +823,6 @@ export function ToolInput({ const newToolId = getToolIdForOperation(tool.type, operation) if (!newToolId) { - logger.info('❌ Early return: no newToolId') return } @@ -840,7 +830,6 @@ export function ToolInput({ const toolParams = getToolParametersConfig(newToolId, tool.type) if (!toolParams) { - logger.info('❌ Early return: no toolParams') return } @@ -1400,7 +1389,7 @@ export function ToolInput({ const newTool: StoredTool = { type: 'custom-tool', title: customTool.title, - toolId: `custom-${customTool.schema.function.name}`, + toolId: `custom-${customTool.schema?.function?.name || 'unknown'}`, params: {}, isExpanded: true, schema: customTool.schema, @@ -1934,7 +1923,7 @@ export function ToolInput({ const newTool: StoredTool = { type: 'custom-tool', title: customTool.title, - toolId: `custom-${customTool.schema.function.name}`, + toolId: `custom-${customTool.schema?.function?.name || 'unknown'}`, params: {}, isExpanded: true, schema: customTool.schema, @@ -2025,8 +2014,8 @@ export function ToolInput({ ? { id: customTools.find( (tool) => - tool.schema.function.name === - selectedTools[editingToolIndex].schema.function.name + tool.schema?.function?.name === + selectedTools[editingToolIndex].schema?.function?.name )?.id, schema: selectedTools[editingToolIndex].schema, code: selectedTools[editingToolIndex].code || '', diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/sub-block.tsx index 988f773b84f..8328eb7be9d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/sub-block.tsx @@ -67,12 +67,61 @@ interface SubBlockProps { } /** - * Returns whether the field is required for validation. Intentionally unused. + * Returns whether the field is required for validation. + * Evaluates conditional requirements based on current field values. * @param config - The sub-block configuration + * @param subBlockValues - Current values of all subblocks * @returns True if the field is required */ -const isFieldRequired = (config: SubBlockConfig): boolean => { - return config.required === true +const isFieldRequired = (config: SubBlockConfig, subBlockValues?: Record): boolean => { + if (!config.required) return false + if (typeof config.required === 'boolean') return config.required + + // Helper function to evaluate a condition + const evalCond = ( + cond: { + field: string + value: string | number | boolean | Array + not?: boolean + and?: { + field: string + value: string | number | boolean | Array | undefined + not?: boolean + } + }, + values: Record + ): boolean => { + const fieldValue = values[cond.field]?.value + const condValue = cond.value + + let match: boolean + if (Array.isArray(condValue)) { + match = condValue.includes(fieldValue) + } else { + match = fieldValue === condValue + } + + if (cond.not) match = !match + + if (cond.and) { + const andFieldValue = values[cond.and.field]?.value + const andCondValue = cond.and.value + let andMatch: boolean + if (Array.isArray(andCondValue)) { + andMatch = andCondValue.includes(andFieldValue) + } else { + andMatch = andFieldValue === andCondValue + } + if (cond.and.not) andMatch = !andMatch + match = match && andMatch + } + + return match + } + + // If required is a condition object or function, evaluate it + const condition = typeof config.required === 'function' ? config.required() : config.required + return evalCond(condition, subBlockValues || {}) } /** @@ -96,6 +145,7 @@ const getPreviewValue = ( * @param config - The sub-block configuration * @param isValidJson - Whether the JSON is valid * @param wandState - Wand interaction state + * @param subBlockValues - Current values of all subblocks for evaluating conditional requirements * @returns The label JSX element or null if no title or for switch types */ const renderLabel = ( @@ -113,7 +163,8 @@ const renderLabel = ( onSearchSubmit: () => void onSearchCancel: () => void searchInputRef: React.RefObject - } + }, + subBlockValues?: Record ): JSX.Element | null => { if (config.type === 'switch') return null if (!config.title) return null @@ -132,10 +183,13 @@ const renderLabel = ( searchInputRef, } = wandState + const required = isFieldRequired(config, subBlockValues) + return (