From 268e2f114f0f317bb0c42125582ddc5b2aa2c1e0 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 21 Jan 2026 09:47:48 -0800 Subject: [PATCH 01/18] fix(zustand): updated to useShallow from deprecated createWithEqualityFn (#2919) --- .../hooks/use-editor-block-properties.ts | 44 +++++++------------ 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-block-properties.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-block-properties.ts index 64f38b213a..837d164bcd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-block-properties.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-block-properties.ts @@ -1,5 +1,4 @@ -import { useCallback } from 'react' -import { shallow } from 'zustand/shallow' +import { useShallow } from 'zustand/react/shallow' import { useWorkflowDiffStore } from '@/stores/workflow-diff' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -13,35 +12,26 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store' */ export function useEditorBlockProperties(blockId: string | null, isSnapshotView: boolean) { const normalBlockProps = useWorkflowStore( - useCallback( - (state) => { - if (!blockId) return { advancedMode: false, triggerMode: false } - const block = state.blocks?.[blockId] - return { - advancedMode: block?.advancedMode ?? false, - triggerMode: block?.triggerMode ?? false, - } - }, - [blockId] - ), - shallow + useShallow((state) => { + if (!blockId) return { advancedMode: false, triggerMode: false } + const block = state.blocks?.[blockId] + return { + advancedMode: block?.advancedMode ?? false, + triggerMode: block?.triggerMode ?? false, + } + }) ) const baselineBlockProps = useWorkflowDiffStore( - useCallback( - (state) => { - if (!blockId) return { advancedMode: false, triggerMode: false } - const block = state.baselineWorkflow?.blocks?.[blockId] - return { - advancedMode: block?.advancedMode ?? false, - triggerMode: block?.triggerMode ?? false, - } - }, - [blockId] - ), - shallow + useShallow((state) => { + if (!blockId) return { advancedMode: false, triggerMode: false } + const block = state.baselineWorkflow?.blocks?.[blockId] + return { + advancedMode: block?.advancedMode ?? false, + triggerMode: block?.triggerMode ?? false, + } + }) ) - // Use the appropriate props based on view mode return isSnapshotView ? baselineBlockProps : normalBlockProps } From ea4964052d6e711c0c035e7d3238e1266216460d Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 21 Jan 2026 10:14:40 -0800 Subject: [PATCH 02/18] fix(logger): use direct env access for webpack inlining (#2920) --- packages/logger/src/index.ts | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts index 4ec9bac7cc..ab84805122 100644 --- a/packages/logger/src/index.ts +++ b/packages/logger/src/index.ts @@ -33,21 +33,19 @@ export interface LoggerConfig { enabled?: boolean } -/** - * Get environment variable value - * Works in any JavaScript runtime (Node.js, Bun, etc.) - */ -const getEnvVar = (key: string): string | undefined => { +const getNodeEnv = (): string => { if (typeof process !== 'undefined' && process.env) { - return process.env[key] + return process.env.NODE_ENV || 'development' } - return undefined + return 'development' } -/** - * Get the current environment (development, production, test) - */ -const getNodeEnv = (): string => getEnvVar('NODE_ENV') || 'development' +const getLogLevel = (): string | undefined => { + if (typeof process !== 'undefined' && process.env) { + return process.env.LOG_LEVEL + } + return undefined +} /** * Get the minimum log level from environment variable or use defaults @@ -56,7 +54,7 @@ const getNodeEnv = (): string => getEnvVar('NODE_ENV') || 'development' * - Test: ERROR (only show errors in tests) */ const getMinLogLevel = (): LogLevel => { - const logLevelEnv = getEnvVar('LOG_LEVEL') + const logLevelEnv = getLogLevel() if (logLevelEnv && Object.values(LogLevel).includes(logLevelEnv as LogLevel)) { return logLevelEnv as LogLevel } @@ -120,7 +118,6 @@ const formatObject = (obj: unknown, isDev: boolean): string => { stack: isDev ? obj.stack : undefined, name: obj.name, } - // Copy any additional enumerable properties from the error for (const key of Object.keys(obj)) { if (!(key in errorObj)) { errorObj[key] = (obj as unknown as Record)[key] @@ -181,7 +178,6 @@ export class Logger { private shouldLog(level: LogLevel): boolean { if (!this.config.enabled) return false - // In production, only log on server-side (where window is undefined) if (getNodeEnv() === 'production' && typeof window !== 'undefined') { return false } From fb8868c854a958d3cc3f4e8168426b299828549a Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 21 Jan 2026 10:20:21 -0800 Subject: [PATCH 03/18] fix(notifications): text overflow with line-clamp (#2921) --- .../notifications/notifications.tsx | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx index e47c2b7f8c..ddd25134fc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx @@ -138,18 +138,24 @@ export const Notifications = memo(function Notifications() { }`} >
-
+
+
+ {notification.level === 'error' && ( + + )} + {notification.message} +
@@ -158,10 +164,6 @@ export const Notifications = memo(function Notifications() { Clear all - {notification.level === 'error' && ( - - )} - {notification.message}
{hasAction && (
)} + {/* Password reset success message */} + {resetSuccessMessage && ( +
+

{resetSuccessMessage}

+
+ )} + {/* Email/Password Form - show unless explicitly disabled */} {!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && (
diff --git a/apps/sim/app/(auth)/reset-password/reset-password-form.tsx b/apps/sim/app/(auth)/reset-password/reset-password-form.tsx index 7212b52d53..d50fbf9868 100644 --- a/apps/sim/app/(auth)/reset-password/reset-password-form.tsx +++ b/apps/sim/app/(auth)/reset-password/reset-password-form.tsx @@ -1,12 +1,13 @@ 'use client' -import { useEffect, useState } from 'react' +import { useState } from 'react' import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { cn } from '@/lib/core/utils/cn' import { inter } from '@/app/_styles/fonts/inter/inter' +import { useBrandedButtonClass } from '@/hooks/use-branded-button-class' interface RequestResetFormProps { email: string @@ -27,36 +28,9 @@ export function RequestResetForm({ statusMessage, className, }: RequestResetFormProps) { - const [buttonClass, setButtonClass] = useState('branded-button-gradient') + const buttonClass = useBrandedButtonClass() const [isButtonHovered, setIsButtonHovered] = useState(false) - useEffect(() => { - const checkCustomBrand = () => { - const computedStyle = getComputedStyle(document.documentElement) - const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim() - - if (brandAccent && brandAccent !== '#6f3dfa') { - setButtonClass('branded-button-custom') - } else { - setButtonClass('branded-button-gradient') - } - } - - checkCustomBrand() - - window.addEventListener('resize', checkCustomBrand) - const observer = new MutationObserver(checkCustomBrand) - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ['style', 'class'], - }) - - return () => { - window.removeEventListener('resize', checkCustomBrand) - observer.disconnect() - } - }, []) - const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() onSubmit(email) @@ -138,36 +112,9 @@ export function SetNewPasswordForm({ const [validationMessage, setValidationMessage] = useState('') const [showPassword, setShowPassword] = useState(false) const [showConfirmPassword, setShowConfirmPassword] = useState(false) - const [buttonClass, setButtonClass] = useState('branded-button-gradient') + const buttonClass = useBrandedButtonClass() const [isButtonHovered, setIsButtonHovered] = useState(false) - useEffect(() => { - const checkCustomBrand = () => { - const computedStyle = getComputedStyle(document.documentElement) - const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim() - - if (brandAccent && brandAccent !== '#6f3dfa') { - setButtonClass('branded-button-custom') - } else { - setButtonClass('branded-button-gradient') - } - } - - checkCustomBrand() - - window.addEventListener('resize', checkCustomBrand) - const observer = new MutationObserver(checkCustomBrand) - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ['style', 'class'], - }) - - return () => { - window.removeEventListener('resize', checkCustomBrand) - observer.disconnect() - } - }, []) - const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 5aeb59fa67..840765de8c 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -16,6 +16,7 @@ import { inter } from '@/app/_styles/fonts/inter/inter' import { soehne } from '@/app/_styles/fonts/soehne/soehne' import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons' import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button' +import { useBrandedButtonClass } from '@/hooks/use-branded-button-class' const logger = createLogger('SignupForm') @@ -95,7 +96,7 @@ function SignupFormContent({ const [showEmailValidationError, setShowEmailValidationError] = useState(false) const [redirectUrl, setRedirectUrl] = useState('') const [isInviteFlow, setIsInviteFlow] = useState(false) - const [buttonClass, setButtonClass] = useState('branded-button-gradient') + const buttonClass = useBrandedButtonClass() const [isButtonHovered, setIsButtonHovered] = useState(false) const [name, setName] = useState('') @@ -126,31 +127,6 @@ function SignupFormContent({ if (inviteFlowParam === 'true') { setIsInviteFlow(true) } - - const checkCustomBrand = () => { - const computedStyle = getComputedStyle(document.documentElement) - const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim() - - if (brandAccent && brandAccent !== '#6f3dfa') { - setButtonClass('branded-button-custom') - } else { - setButtonClass('branded-button-gradient') - } - } - - checkCustomBrand() - - window.addEventListener('resize', checkCustomBrand) - const observer = new MutationObserver(checkCustomBrand) - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ['style', 'class'], - }) - - return () => { - window.removeEventListener('resize', checkCustomBrand) - observer.disconnect() - } }, [searchParams]) const validatePassword = (passwordValue: string): string[] => { diff --git a/apps/sim/app/(auth)/sso/sso-form.tsx b/apps/sim/app/(auth)/sso/sso-form.tsx index 0d371bbaff..12901c51c2 100644 --- a/apps/sim/app/(auth)/sso/sso-form.tsx +++ b/apps/sim/app/(auth)/sso/sso-form.tsx @@ -13,6 +13,7 @@ import { cn } from '@/lib/core/utils/cn' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { inter } from '@/app/_styles/fonts/inter/inter' import { soehne } from '@/app/_styles/fonts/soehne/soehne' +import { useBrandedButtonClass } from '@/hooks/use-branded-button-class' const logger = createLogger('SSOForm') @@ -57,7 +58,7 @@ export default function SSOForm() { const [email, setEmail] = useState('') const [emailErrors, setEmailErrors] = useState([]) const [showEmailValidationError, setShowEmailValidationError] = useState(false) - const [buttonClass, setButtonClass] = useState('branded-button-gradient') + const buttonClass = useBrandedButtonClass() const [callbackUrl, setCallbackUrl] = useState('/workspace') useEffect(() => { @@ -90,31 +91,6 @@ export default function SSOForm() { setShowEmailValidationError(true) } } - - const checkCustomBrand = () => { - const computedStyle = getComputedStyle(document.documentElement) - const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim() - - if (brandAccent && brandAccent !== '#6f3dfa') { - setButtonClass('branded-button-custom') - } else { - setButtonClass('branded-button-gradient') - } - } - - checkCustomBrand() - - window.addEventListener('resize', checkCustomBrand) - const observer = new MutationObserver(checkCustomBrand) - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ['style', 'class'], - }) - - return () => { - window.removeEventListener('resize', checkCustomBrand) - observer.disconnect() - } }, [searchParams]) const handleEmailChange = (e: React.ChangeEvent) => { diff --git a/apps/sim/app/(auth)/verify/verify-content.tsx b/apps/sim/app/(auth)/verify/verify-content.tsx index ed05354b94..0eb41b8ba0 100644 --- a/apps/sim/app/(auth)/verify/verify-content.tsx +++ b/apps/sim/app/(auth)/verify/verify-content.tsx @@ -8,6 +8,7 @@ import { cn } from '@/lib/core/utils/cn' import { inter } from '@/app/_styles/fonts/inter/inter' import { soehne } from '@/app/_styles/fonts/soehne/soehne' import { useVerification } from '@/app/(auth)/verify/use-verification' +import { useBrandedButtonClass } from '@/hooks/use-branded-button-class' interface VerifyContentProps { hasEmailService: boolean @@ -58,34 +59,7 @@ function VerificationForm({ setCountdown(30) } - const [buttonClass, setButtonClass] = useState('branded-button-gradient') - - useEffect(() => { - const checkCustomBrand = () => { - const computedStyle = getComputedStyle(document.documentElement) - const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim() - - if (brandAccent && brandAccent !== '#6f3dfa') { - setButtonClass('branded-button-custom') - } else { - setButtonClass('branded-button-gradient') - } - } - - checkCustomBrand() - - window.addEventListener('resize', checkCustomBrand) - const observer = new MutationObserver(checkCustomBrand) - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ['style', 'class'], - }) - - return () => { - window.removeEventListener('resize', checkCustomBrand) - observer.disconnect() - } - }, []) + const buttonClass = useBrandedButtonClass() return ( <> diff --git a/apps/sim/app/api/auth/reset-password/route.ts b/apps/sim/app/api/auth/reset-password/route.ts index 0caa1494f2..1d47be1035 100644 --- a/apps/sim/app/api/auth/reset-password/route.ts +++ b/apps/sim/app/api/auth/reset-password/route.ts @@ -15,7 +15,8 @@ const resetPasswordSchema = z.object({ .max(100, 'Password must not exceed 100 characters') .regex(/[A-Z]/, 'Password must contain at least one uppercase letter') .regex(/[a-z]/, 'Password must contain at least one lowercase letter') - .regex(/[0-9]/, 'Password must contain at least one number'), + .regex(/[0-9]/, 'Password must contain at least one number') + .regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'), }) export async function POST(request: NextRequest) { From 8bbcf31b83b81a2067867f3fba0b4f9831c33b14 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 21 Jan 2026 14:54:29 -0800 Subject: [PATCH 06/18] fix(action-bar): duplicate subflows with children (#2923) * fix(action-bar): duplicate subflows with children * fix(action-bar): add validateTriggerPaste for subflow duplicate --- .../components/action-bar/action-bar.tsx | 49 +++++++++++-------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx index e2fa63da7c..9f10058817 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx @@ -4,10 +4,10 @@ import { Button, Copy, Tooltip, Trash2 } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { validateTriggerPaste } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' +import { useNotificationStore } from '@/stores/notifications' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' -import { getUniqueBlockName, prepareDuplicateBlockState } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' const DEFAULT_DUPLICATE_OFFSET = { x: 50, y: 50 } @@ -48,29 +48,38 @@ export const ActionBar = memo( collaborativeBatchToggleBlockEnabled, collaborativeBatchToggleBlockHandles, } = useCollaborativeWorkflow() - const { activeWorkflowId, setPendingSelection } = useWorkflowRegistry() + const { setPendingSelection } = useWorkflowRegistry() + + const addNotification = useNotificationStore((s) => s.addNotification) const handleDuplicateBlock = useCallback(() => { - const blocks = useWorkflowStore.getState().blocks - const sourceBlock = blocks[blockId] - if (!sourceBlock) return + const { copyBlocks, preparePasteData, activeWorkflowId } = useWorkflowRegistry.getState() + const existingBlocks = useWorkflowStore.getState().blocks + copyBlocks([blockId]) - const newId = crypto.randomUUID() - const newName = getUniqueBlockName(sourceBlock.name, blocks) - const subBlockValues = - useSubBlockStore.getState().workflowValues[activeWorkflowId || '']?.[blockId] || {} + const pasteData = preparePasteData(DEFAULT_DUPLICATE_OFFSET) + if (!pasteData) return - const { block, subBlockValues: filteredValues } = prepareDuplicateBlockState({ - sourceBlock, - newId, - newName, - positionOffset: DEFAULT_DUPLICATE_OFFSET, - subBlockValues, - }) + const blocks = Object.values(pasteData.blocks) + const validation = validateTriggerPaste(blocks, existingBlocks, 'duplicate') + if (!validation.isValid) { + addNotification({ + level: 'error', + message: validation.message!, + workflowId: activeWorkflowId || undefined, + }) + return + } - setPendingSelection([newId]) - collaborativeBatchAddBlocks([block], [], {}, {}, { [newId]: filteredValues }) - }, [blockId, activeWorkflowId, collaborativeBatchAddBlocks, setPendingSelection]) + setPendingSelection(blocks.map((b) => b.id)) + collaborativeBatchAddBlocks( + blocks, + pasteData.edges, + pasteData.loops, + pasteData.parallels, + pasteData.subBlockValues + ) + }, [blockId, addNotification, collaborativeBatchAddBlocks, setPendingSelection]) const { isEnabled, horizontalHandles, parentId, parentType } = useWorkflowStore( useCallback( From 5157f0bbb21b2955b5ca677441c0aa20a062c0fd Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 21 Jan 2026 14:55:23 -0800 Subject: [PATCH 07/18] fix(resolver): agent response format, input formats, root level (#2925) * fix(resolvers): agent response format, input formats, root level * fix response block initial seeding * fix tests --- .../app/api/function/execute/route.test.ts | 89 ++++---- apps/sim/app/api/function/execute/route.ts | 214 +++++------------- .../sub-block/components/code/code.tsx | 2 +- .../condition-input/condition-input.tsx | 2 +- .../sub-block/components/formatted-text.tsx | 3 +- .../components/tag-dropdown/tag-dropdown.tsx | 2 +- .../human-in-the-loop-handler.ts | 26 +-- .../handlers/response/response-handler.ts | 26 +-- apps/sim/executor/utils/json.ts | 27 +++ .../variables/resolvers/block.test.ts | 64 ++---- .../sim/executor/variables/resolvers/block.ts | 26 +-- apps/sim/hooks/use-collaborative-workflow.ts | 3 +- .../sim/lib/workflows/blocks/block-outputs.ts | 24 ++ apps/sim/stores/panel/variables/store.ts | 2 +- apps/sim/stores/variables/store.ts | 2 +- apps/sim/stores/workflows/utils.test.ts | 3 +- apps/sim/stores/workflows/utils.ts | 4 +- apps/sim/stores/workflows/workflow/store.ts | 8 +- 18 files changed, 200 insertions(+), 327 deletions(-) diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index 45abbb3212..ea020abaf5 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -276,8 +276,11 @@ describe('Function Execute API Route', () => { it.concurrent('should resolve tag variables with syntax', async () => { const req = createMockRequest('POST', { code: 'return ', - params: { - email: { id: '123', subject: 'Test Email' }, + blockData: { + 'block-123': { id: '123', subject: 'Test Email' }, + }, + blockNameMapping: { + email: 'block-123', }, }) @@ -305,9 +308,13 @@ describe('Function Execute API Route', () => { it.concurrent('should only match valid variable names in angle brackets', async () => { const req = createMockRequest('POST', { code: 'return + "" + ', - params: { - validVar: 'hello', - another_valid: 'world', + blockData: { + 'block-1': 'hello', + 'block-2': 'world', + }, + blockNameMapping: { + validVar: 'block-1', + another_valid: 'block-2', }, }) @@ -321,28 +328,22 @@ describe('Function Execute API Route', () => { it.concurrent( 'should handle Gmail webhook data with email addresses containing angle brackets', async () => { - const gmailData = { - email: { - id: '123', - from: 'Waleed Latif ', - to: 'User ', - subject: 'Test Email', - bodyText: 'Hello world', - }, - rawEmail: { - id: '123', - payload: { - headers: [ - { name: 'From', value: 'Waleed Latif ' }, - { name: 'To', value: 'User ' }, - ], - }, - }, + const emailData = { + id: '123', + from: 'Waleed Latif ', + to: 'User ', + subject: 'Test Email', + bodyText: 'Hello world', } const req = createMockRequest('POST', { code: 'return ', - params: gmailData, + blockData: { + 'block-email': emailData, + }, + blockNameMapping: { + email: 'block-email', + }, }) const response = await POST(req) @@ -356,17 +357,20 @@ describe('Function Execute API Route', () => { it.concurrent( 'should properly serialize complex email objects with special characters', async () => { - const complexEmailData = { - email: { - from: 'Test User ', - bodyHtml: '
HTML content with "quotes" and \'apostrophes\'
', - bodyText: 'Text with\nnewlines\tand\ttabs', - }, + const emailData = { + from: 'Test User ', + bodyHtml: '
HTML content with "quotes" and \'apostrophes\'
', + bodyText: 'Text with\nnewlines\tand\ttabs', } const req = createMockRequest('POST', { code: 'return ', - params: complexEmailData, + blockData: { + 'block-email': emailData, + }, + blockNameMapping: { + email: 'block-email', + }, }) const response = await POST(req) @@ -519,18 +523,23 @@ describe('Function Execute API Route', () => { }) it.concurrent('should handle JSON serialization edge cases', async () => { + const complexData = { + special: 'chars"with\'quotes', + unicode: '🎉 Unicode content', + nested: { + deep: { + value: 'test', + }, + }, + } + const req = createMockRequest('POST', { code: 'return ', - params: { - complexData: { - special: 'chars"with\'quotes', - unicode: '🎉 Unicode content', - nested: { - deep: { - value: 'test', - }, - }, - }, + blockData: { + 'block-complex': complexData, + }, + blockNameMapping: { + complexData: 'block-complex', }, }) diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 4412cf9667..8868c2d404 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -9,8 +9,8 @@ import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants' import { createEnvVarPattern, createWorkflowVariablePattern, - resolveEnvVarReferences, } from '@/executor/utils/reference-validation' +import { navigatePath } from '@/executor/variables/resolvers/reference' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' @@ -18,8 +18,8 @@ export const MAX_DURATION = 210 const logger = createLogger('FunctionExecuteAPI') -const E2B_JS_WRAPPER_LINES = 3 // Lines before user code: ';(async () => {', ' try {', ' const __sim_result = await (async () => {' -const E2B_PYTHON_WRAPPER_LINES = 1 // Lines before user code: 'def __sim_main__():' +const E2B_JS_WRAPPER_LINES = 3 +const E2B_PYTHON_WRAPPER_LINES = 1 type TypeScriptModule = typeof import('typescript') @@ -134,33 +134,21 @@ function extractEnhancedError( if (error.stack) { enhanced.stack = error.stack - // Parse stack trace to extract line and column information - // Handle both compilation errors and runtime errors const stackLines: string[] = error.stack.split('\n') for (const line of stackLines) { - // Pattern 1: Compilation errors - "user-function.js:6" let match = line.match(/user-function\.js:(\d+)(?::(\d+))?/) - // Pattern 2: Runtime errors - "at user-function.js:5:12" if (!match) { match = line.match(/at\s+user-function\.js:(\d+):(\d+)/) } - // Pattern 3: Generic patterns for any line containing our filename - if (!match) { - match = line.match(/user-function\.js:(\d+)(?::(\d+))?/) - } - if (match) { const stackLine = Number.parseInt(match[1], 10) const stackColumn = match[2] ? Number.parseInt(match[2], 10) : undefined - // Adjust line number to account for wrapper code - // The user code starts at a specific line in our wrapper const adjustedLine = stackLine - userCodeStartLine + 1 - // Check if this is a syntax error in wrapper code caused by incomplete user code const isWrapperSyntaxError = stackLine > userCodeStartLine && error.name === 'SyntaxError' && @@ -168,7 +156,6 @@ function extractEnhancedError( error.message.includes('Unexpected end of input')) if (isWrapperSyntaxError && userCode) { - // Map wrapper syntax errors to the last line of user code const codeLines = userCode.split('\n') const lastUserLine = codeLines.length enhanced.line = lastUserLine @@ -181,7 +168,6 @@ function extractEnhancedError( enhanced.line = adjustedLine enhanced.column = stackColumn - // Extract the actual line content from user code if (userCode) { const codeLines = userCode.split('\n') if (adjustedLine <= codeLines.length) { @@ -192,7 +178,6 @@ function extractEnhancedError( } if (stackLine <= userCodeStartLine) { - // Error is in wrapper code itself enhanced.line = stackLine enhanced.column = stackColumn break @@ -200,7 +185,6 @@ function extractEnhancedError( } } - // Clean up stack trace to show user-relevant information const cleanedStackLines: string[] = stackLines .filter( (line: string) => @@ -214,9 +198,6 @@ function extractEnhancedError( } } - // Keep original message without adding error type prefix - // The error type will be added later in createUserFriendlyErrorMessage - return enhanced } @@ -231,7 +212,6 @@ function formatE2BError( userCode: string, prologueLineCount: number ): { formattedError: string; cleanedOutput: string } { - // Calculate line offset based on language and prologue const wrapperLines = language === CodeLanguage.Python ? E2B_PYTHON_WRAPPER_LINES : E2B_JS_WRAPPER_LINES const totalOffset = prologueLineCount + wrapperLines @@ -241,27 +221,20 @@ function formatE2BError( let cleanErrorMsg = '' if (language === CodeLanguage.Python) { - // Python error format: "Cell In[X], line Y" followed by error details - // Extract line number from the Cell reference const cellMatch = errorOutput.match(/Cell In\[\d+\], line (\d+)/) if (cellMatch) { const originalLine = Number.parseInt(cellMatch[1], 10) userLine = originalLine - totalOffset } - // Extract clean error message from the error string - // Remove file references like "(detected at line X) (file.py, line Y)" cleanErrorMsg = errorMessage .replace(/\s*\(detected at line \d+\)/g, '') .replace(/\s*\([^)]+\.py, line \d+\)/g, '') .trim() } else if (language === CodeLanguage.JavaScript) { - // JavaScript error format from E2B: "SyntaxError: /path/file.ts: Message. (line:col)\n\n 9 | ..." - // First, extract the error type and message from the first line const firstLineEnd = errorMessage.indexOf('\n') const firstLine = firstLineEnd > 0 ? errorMessage.substring(0, firstLineEnd) : errorMessage - // Parse: "SyntaxError: /home/user/index.ts: Missing semicolon. (11:9)" const jsErrorMatch = firstLine.match(/^(\w+Error):\s*[^:]+:\s*([^(]+)\.\s*\((\d+):(\d+)\)/) if (jsErrorMatch) { cleanErrorType = jsErrorMatch[1] @@ -269,13 +242,11 @@ function formatE2BError( const originalLine = Number.parseInt(jsErrorMatch[3], 10) userLine = originalLine - totalOffset } else { - // Fallback: look for line number in the arrow pointer line (> 11 |) const arrowMatch = errorMessage.match(/^>\s*(\d+)\s*\|/m) if (arrowMatch) { const originalLine = Number.parseInt(arrowMatch[1], 10) userLine = originalLine - totalOffset } - // Try to extract error type and message const errorMatch = firstLine.match(/^(\w+Error):\s*(.+)/) if (errorMatch) { cleanErrorType = errorMatch[1] @@ -289,13 +260,11 @@ function formatE2BError( } } - // Build the final clean error message const finalErrorMsg = cleanErrorType && cleanErrorMsg ? `${cleanErrorType}: ${cleanErrorMsg}` : cleanErrorMsg || errorMessage - // Format with line number if available let formattedError = finalErrorMsg if (userLine && userLine > 0) { const codeLines = userCode.split('\n') @@ -311,7 +280,6 @@ function formatE2BError( } } - // For stdout, just return the clean error message without the full traceback const cleanedOutput = finalErrorMsg return { formattedError, cleanedOutput } @@ -327,7 +295,6 @@ function createUserFriendlyErrorMessage( ): string { let errorMessage = enhanced.message - // Add line information if available if (enhanced.line !== undefined) { let lineInfo = `Line ${enhanced.line}` @@ -338,18 +305,14 @@ function createUserFriendlyErrorMessage( errorMessage = `${lineInfo} - ${errorMessage}` } else { - // If no line number, try to extract it from stack trace for display if (enhanced.stack) { const stackMatch = enhanced.stack.match(/user-function\.js:(\d+)(?::(\d+))?/) if (stackMatch) { const line = Number.parseInt(stackMatch[1], 10) let lineInfo = `Line ${line}` - // Try to get line content if we have userCode if (userCode) { const codeLines = userCode.split('\n') - // Note: stackMatch gives us VM line number, need to adjust - // This is a fallback case, so we might not have perfect line mapping if (line <= codeLines.length) { const lineContent = codeLines[line - 1]?.trim() if (lineContent) { @@ -363,7 +326,6 @@ function createUserFriendlyErrorMessage( } } - // Add error type prefix with consistent naming if (enhanced.name !== 'Error') { const errorTypePrefix = enhanced.name === 'SyntaxError' @@ -374,7 +336,6 @@ function createUserFriendlyErrorMessage( ? 'Reference Error' : enhanced.name - // Only add prefix if not already present if (!errorMessage.toLowerCase().includes(errorTypePrefix.toLowerCase())) { errorMessage = `${errorTypePrefix}: ${errorMessage}` } @@ -383,9 +344,6 @@ function createUserFriendlyErrorMessage( return errorMessage } -/** - * Resolves workflow variables with syntax - */ function resolveWorkflowVariables( code: string, workflowVariables: Record, @@ -405,39 +363,35 @@ function resolveWorkflowVariables( while ((match = regex.exec(code)) !== null) { const variableName = match[1].trim() - // Find the variable by name (workflowVariables is indexed by ID, values are variable objects) const foundVariable = Object.entries(workflowVariables).find( ([_, variable]) => normalizeName(variable.name || '') === variableName ) - let variableValue: unknown = '' - if (foundVariable) { - const variable = foundVariable[1] - variableValue = variable.value + if (!foundVariable) { + const availableVars = Object.values(workflowVariables) + .map((v) => v.name) + .filter(Boolean) + throw new Error( + `Variable "${variableName}" doesn't exist.` + + (availableVars.length > 0 ? ` Available: ${availableVars.join(', ')}` : '') + ) + } + + const variable = foundVariable[1] + let variableValue: unknown = variable.value - if (variable.value !== undefined && variable.value !== null) { + if (variable.value !== undefined && variable.value !== null) { + const type = variable.type === 'string' ? 'plain' : variable.type + + if (type === 'number') { + variableValue = Number(variableValue) + } else if (type === 'boolean') { + variableValue = variableValue === 'true' || variableValue === true + } else if (type === 'json' && typeof variableValue === 'string') { try { - // Handle 'string' type the same as 'plain' for backward compatibility - const type = variable.type === 'string' ? 'plain' : variable.type - - // For plain text, use exactly what's entered without modifications - if (type === 'plain' && typeof variableValue === 'string') { - // Use as-is for plain text - } else if (type === 'number') { - variableValue = Number(variableValue) - } else if (type === 'boolean') { - variableValue = variableValue === 'true' || variableValue === true - } else if (type === 'json') { - try { - variableValue = - typeof variableValue === 'string' ? JSON.parse(variableValue) : variableValue - } catch { - // Keep original value if JSON parsing fails - } - } + variableValue = JSON.parse(variableValue) } catch { - // Fallback to original value on error - variableValue = variable.value + // Keep as-is } } } @@ -450,11 +404,9 @@ function resolveWorkflowVariables( }) } - // Process replacements in reverse order to maintain correct indices for (let i = replacements.length - 1; i >= 0; i--) { const { match: matchStr, index, variableName, variableValue } = replacements[i] - // Use variable reference approach const safeVarName = `__variable_${variableName.replace(/[^a-zA-Z0-9_]/g, '_')}` contextVariables[safeVarName] = variableValue resolvedCode = @@ -464,9 +416,6 @@ function resolveWorkflowVariables( return resolvedCode } -/** - * Resolves environment variables with {{var_name}} syntax - */ function resolveEnvironmentVariables( code: string, params: Record, @@ -482,32 +431,28 @@ function resolveEnvironmentVariables( const resolverVars: Record = {} Object.entries(params).forEach(([key, value]) => { - if (value) { + if (value !== undefined && value !== null) { resolverVars[key] = String(value) } }) Object.entries(envVars).forEach(([key, value]) => { - if (value) { + if (value !== undefined && value !== null) { 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) + + if (!(varName in resolverVars)) { + continue + } + replacements.push({ match: match[0], index: match.index, varName, - varValue: String(varValue), + varValue: resolverVars[varName], }) } @@ -523,12 +468,8 @@ function resolveEnvironmentVariables( return resolvedCode } -/** - * Resolves tags with syntax (including nested paths like ) - */ function resolveTagVariables( code: string, - params: Record, blockData: Record, blockNameMapping: Record, contextVariables: Record @@ -543,27 +484,30 @@ function resolveTagVariables( for (const match of tagMatches) { const tagName = match.slice(REFERENCE.START.length, -REFERENCE.END.length).trim() + const pathParts = tagName.split(REFERENCE.PATH_DELIMITER) + const blockName = pathParts[0] - // Handle nested paths like "getrecord.response.data" or "function1.response.result" - // First try params, then blockData directly, then try with block name mapping - let tagValue = getNestedValue(params, tagName) || getNestedValue(blockData, tagName) || '' + const blockId = blockNameMapping[blockName] + if (!blockId) { + continue + } - // If not found and the path starts with a block name, try mapping the block name to ID - if (!tagValue && tagName.includes(REFERENCE.PATH_DELIMITER)) { - const pathParts = tagName.split(REFERENCE.PATH_DELIMITER) - const normalizedBlockName = pathParts[0] // This should already be normalized like "function1" + const blockOutput = blockData[blockId] + if (blockOutput === undefined) { + continue + } - // Direct lookup using normalized block name - const blockId = blockNameMapping[normalizedBlockName] ?? null + let tagValue: any + if (pathParts.length === 1) { + tagValue = blockOutput + } else { + tagValue = navigatePath(blockOutput, pathParts.slice(1)) + } - if (blockId) { - const remainingPath = pathParts.slice(1).join('.') - const fullPath = `${blockId}.${remainingPath}` - tagValue = getNestedValue(blockData, fullPath) || '' - } + if (tagValue === undefined) { + continue } - // If the value is a stringified JSON, parse it back to object if ( typeof tagValue === 'string' && tagValue.length > 100 && @@ -571,16 +515,13 @@ function resolveTagVariables( ) { try { tagValue = JSON.parse(tagValue) - } catch (e) { - // Keep as string if parsing fails + } catch { + // Keep as-is } } - // Instead of injecting large JSON directly, create a variable reference const safeVarName = `__tag_${tagName.replace(/[^a-zA-Z0-9_]/g, '_')}` contextVariables[safeVarName] = tagValue - - // Replace the template with a variable reference resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), safeVarName) } @@ -605,35 +546,13 @@ function resolveCodeVariables( let resolvedCode = code const contextVariables: Record = {} - // Resolve workflow variables with syntax first resolvedCode = resolveWorkflowVariables(resolvedCode, workflowVariables, contextVariables) - - // Resolve environment variables with {{var_name}} syntax resolvedCode = resolveEnvironmentVariables(resolvedCode, params, envVars, contextVariables) - - // Resolve tags with syntax (including nested paths like ) - resolvedCode = resolveTagVariables( - resolvedCode, - params, - blockData, - blockNameMapping, - contextVariables - ) + resolvedCode = resolveTagVariables(resolvedCode, blockData, blockNameMapping, contextVariables) return { resolvedCode, contextVariables } } -/** - * Get nested value from object using dot notation path - */ -function getNestedValue(obj: any, path: string): any { - if (!obj || !path) return undefined - - return path.split('.').reduce((current, key) => { - return current && typeof current === 'object' ? current[key] : undefined - }, obj) -} - /** * Remove one trailing newline from stdout * This handles the common case where print() or console.log() adds a trailing \n @@ -671,7 +590,6 @@ export async function POST(req: NextRequest) { isCustomTool = false, } = body - // Extract internal parameters that shouldn't be passed to the execution context const executionParams = { ...params } executionParams._context = undefined @@ -697,7 +615,6 @@ export async function POST(req: NextRequest) { const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE - // Extract imports once for JavaScript code (reuse later to avoid double extraction) let jsImports = '' let jsRemainingCode = resolvedCode let hasImports = false @@ -707,31 +624,22 @@ export async function POST(req: NextRequest) { jsImports = extractionResult.imports jsRemainingCode = extractionResult.remainingCode - // Check for ES6 imports or CommonJS require statements - // ES6 imports are extracted by the TypeScript parser - // Also check for require() calls which indicate external dependencies const hasRequireStatements = /require\s*\(\s*['"`]/.test(resolvedCode) hasImports = jsImports.trim().length > 0 || hasRequireStatements } - // Python always requires E2B if (lang === CodeLanguage.Python && !isE2bEnabled) { throw new Error( 'Python execution requires E2B to be enabled. Please contact your administrator to enable E2B, or use JavaScript instead.' ) } - // JavaScript with imports requires E2B if (lang === CodeLanguage.JavaScript && hasImports && !isE2bEnabled) { throw new Error( 'JavaScript code with import statements requires E2B to be enabled. Please remove the import statements, or contact your administrator to enable E2B.' ) } - // Use E2B if: - // - E2B is enabled AND - // - Not a custom tool AND - // - (Python OR JavaScript with imports) const useE2B = isE2bEnabled && !isCustomTool && @@ -744,13 +652,10 @@ export async function POST(req: NextRequest) { language: lang, }) let prologue = '' - const epilogue = '' if (lang === CodeLanguage.JavaScript) { - // Track prologue lines for error adjustment let prologueLineCount = 0 - // Reuse the imports we already extracted earlier const imports = jsImports const remainingCode = jsRemainingCode @@ -782,7 +687,7 @@ export async function POST(req: NextRequest) { ' }', '})();', ].join('\n') - const codeForE2B = importSection + prologue + wrapped + epilogue + const codeForE2B = importSection + prologue + wrapped const execStart = Date.now() const { @@ -804,7 +709,6 @@ export async function POST(req: NextRequest) { error: e2bError, }) - // If there was an execution error, format it properly if (e2bError) { const { formattedError, cleanedOutput } = formatE2BError( e2bError, @@ -828,7 +732,7 @@ export async function POST(req: NextRequest) { output: { result: e2bResult ?? null, stdout: cleanStdout(stdout), executionTime }, }) } - // Track prologue lines for error adjustment + let prologueLineCount = 0 prologue += 'import json\n' prologueLineCount++ @@ -846,7 +750,7 @@ export async function POST(req: NextRequest) { '__sim_result__ = __sim_main__()', "print('__SIM_RESULT__=' + json.dumps(__sim_result__))", ].join('\n') - const codeForE2B = prologue + wrapped + epilogue + const codeForE2B = prologue + wrapped const execStart = Date.now() const { @@ -868,7 +772,6 @@ export async function POST(req: NextRequest) { error: e2bError, }) - // If there was an execution error, format it properly if (e2bError) { const { formattedError, cleanedOutput } = formatE2BError( e2bError, @@ -897,7 +800,6 @@ export async function POST(req: NextRequest) { const wrapperLines = ['(async () => {', ' try {'] if (isCustomTool) { - wrapperLines.push(' // For custom tools, make parameters directly accessible') Object.keys(executionParams).forEach((key) => { wrapperLines.push(` const ${key} = params.${key};`) }) @@ -931,12 +833,10 @@ export async function POST(req: NextRequest) { }) const ivmError = isolatedResult.error - // Adjust line number for prepended param destructuring in custom tools let adjustedLine = ivmError.line let adjustedLineContent = ivmError.lineContent if (prependedLineCount > 0 && ivmError.line !== undefined) { adjustedLine = Math.max(1, ivmError.line - prependedLineCount) - // Get line content from original user code, not the prepended code const codeLines = resolvedCode.split('\n') if (adjustedLine <= codeLines.length) { adjustedLineContent = codeLines[adjustedLine - 1]?.trim() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx index fab801432b..f646c7601a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx @@ -35,9 +35,9 @@ import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/comp import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand' import type { GenerationType } from '@/blocks/types' +import { normalizeName } from '@/executor/constants' import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation' import { useTagSelection } from '@/hooks/kb/use-tag-selection' -import { normalizeName } from '@/stores/workflows/utils' const logger = createLogger('Code') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx index c614f3662a..5fb97abc52 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx @@ -32,9 +32,9 @@ import { } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' 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 { normalizeName } from '@/executor/constants' import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation' import { useTagSelection } from '@/hooks/kb/use-tag-selection' -import { normalizeName } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' const logger = createLogger('ConditionInput') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text.tsx index f3cc576863..09183d1b30 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text.tsx @@ -2,9 +2,8 @@ import type { ReactNode } from 'react' import { splitReferenceSegment } from '@/lib/workflows/sanitization/references' -import { REFERENCE } from '@/executor/constants' +import { normalizeName, REFERENCE } from '@/executor/constants' import { createCombinedPattern } from '@/executor/utils/reference-validation' -import { normalizeName } from '@/stores/workflows/utils' export interface HighlightContext { accessiblePrefixes?: Set diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx index ebe6cb653b..37d2b57c2c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx @@ -35,11 +35,11 @@ import type { import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import { getBlock } from '@/blocks' import type { BlockConfig } from '@/blocks/types' +import { normalizeName } from '@/executor/constants' import type { Variable } from '@/stores/panel' import { useVariablesStore } from '@/stores/panel' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' -import { normalizeName } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { BlockState } from '@/stores/workflows/workflow/types' diff --git a/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts b/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts index e7ba38543c..dd53a0a0e1 100644 --- a/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts +++ b/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts @@ -17,6 +17,7 @@ import { } from '@/executor/human-in-the-loop/utils' import type { BlockHandler, ExecutionContext, PauseMetadata } from '@/executor/types' import { collectBlockData } from '@/executor/utils/block-data' +import { parseObjectStrings } from '@/executor/utils/json' import type { SerializedBlock } from '@/serializer/types' import { executeTool } from '@/tools' @@ -265,7 +266,7 @@ export class HumanInTheLoopBlockHandler implements BlockHandler { if (dataMode === 'structured' && inputs.builderData) { const convertedData = this.convertBuilderDataToJson(inputs.builderData) - return this.parseObjectStrings(convertedData) + return parseObjectStrings(convertedData) } return inputs.data || {} @@ -485,29 +486,6 @@ export class HumanInTheLoopBlockHandler implements BlockHandler { ) } - private parseObjectStrings(data: any): any { - if (typeof data === 'string') { - try { - const parsed = JSON.parse(data) - if (typeof parsed === 'object' && parsed !== null) { - return this.parseObjectStrings(parsed) - } - return parsed - } catch { - return data - } - } else if (Array.isArray(data)) { - return data.map((item) => this.parseObjectStrings(item)) - } else if (typeof data === 'object' && data !== null) { - const result: any = {} - for (const [key, value] of Object.entries(data)) { - result[key] = this.parseObjectStrings(value) - } - return result - } - return data - } - private parseStatus(status?: string): number { if (!status) return HTTP.STATUS.OK const parsed = Number(status) diff --git a/apps/sim/executor/handlers/response/response-handler.ts b/apps/sim/executor/handlers/response/response-handler.ts index 7cb15495b7..8a3abb22fb 100644 --- a/apps/sim/executor/handlers/response/response-handler.ts +++ b/apps/sim/executor/handlers/response/response-handler.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { BlockType, HTTP, REFERENCE } from '@/executor/constants' import type { BlockHandler, ExecutionContext, NormalizedBlockOutput } from '@/executor/types' +import { parseObjectStrings } from '@/executor/utils/json' import type { SerializedBlock } from '@/serializer/types' const logger = createLogger('ResponseBlockHandler') @@ -73,7 +74,7 @@ export class ResponseBlockHandler implements BlockHandler { if (dataMode === 'structured' && inputs.builderData) { const convertedData = this.convertBuilderDataToJson(inputs.builderData) - return this.parseObjectStrings(convertedData) + return parseObjectStrings(convertedData) } return inputs.data || {} @@ -222,29 +223,6 @@ export class ResponseBlockHandler implements BlockHandler { ) } - private parseObjectStrings(data: any): any { - if (typeof data === 'string') { - try { - const parsed = JSON.parse(data) - if (typeof parsed === 'object' && parsed !== null) { - return this.parseObjectStrings(parsed) - } - return parsed - } catch { - return data - } - } else if (Array.isArray(data)) { - return data.map((item) => this.parseObjectStrings(item)) - } else if (typeof data === 'object' && data !== null) { - const result: any = {} - for (const [key, value] of Object.entries(data)) { - result[key] = this.parseObjectStrings(value) - } - return result - } - return data - } - private parseStatus(status?: string): number { if (!status) return HTTP.STATUS.OK const parsed = Number(status) diff --git a/apps/sim/executor/utils/json.ts b/apps/sim/executor/utils/json.ts index c838d8278b..ceeea98a33 100644 --- a/apps/sim/executor/utils/json.ts +++ b/apps/sim/executor/utils/json.ts @@ -40,3 +40,30 @@ export function isJSONString(value: string): boolean { const trimmed = value.trim() return trimmed.startsWith('{') || trimmed.startsWith('[') } + +/** + * Recursively parses JSON strings within an object or array. + * Useful for normalizing data that may contain stringified JSON at various levels. + */ +export function parseObjectStrings(data: unknown): unknown { + if (typeof data === 'string') { + try { + const parsed = JSON.parse(data) + if (typeof parsed === 'object' && parsed !== null) { + return parseObjectStrings(parsed) + } + return parsed + } catch { + return data + } + } else if (Array.isArray(data)) { + return data.map((item) => parseObjectStrings(item)) + } else if (typeof data === 'object' && data !== null) { + const result: Record = {} + for (const [key, value] of Object.entries(data)) { + result[key] = parseObjectStrings(value) + } + return result + } + return data +} diff --git a/apps/sim/executor/variables/resolvers/block.test.ts b/apps/sim/executor/variables/resolvers/block.test.ts index dac00ee0b0..f08b22fc27 100644 --- a/apps/sim/executor/variables/resolvers/block.test.ts +++ b/apps/sim/executor/variables/resolvers/block.test.ts @@ -6,6 +6,10 @@ import type { ResolutionContext } from './reference' vi.mock('@sim/logger', () => loggerMock) +vi.mock('@/lib/workflows/blocks/block-outputs', () => ({ + getBlockOutputs: vi.fn(() => ({})), +})) + function createTestWorkflow( blocks: Array<{ id: string @@ -140,16 +144,21 @@ describe('BlockResolver', () => { expect(resolver.resolve('', ctx)).toBeUndefined() }) - it.concurrent('should throw error for path not in output schema', () => { + it.concurrent('should throw error for path not in output schema', async () => { + const { getBlockOutputs } = await import('@/lib/workflows/blocks/block-outputs') + const mockGetBlockOutputs = vi.mocked(getBlockOutputs) + const customOutputs = { + validField: { type: 'string', description: 'A valid field' }, + nested: { + child: { type: 'number', description: 'Nested child' }, + }, + } + mockGetBlockOutputs.mockReturnValue(customOutputs as any) + const workflow = createTestWorkflow([ { id: 'source', - outputs: { - validField: { type: 'string', description: 'A valid field' }, - nested: { - child: { type: 'number', description: 'Nested child' }, - }, - }, + outputs: customOutputs, }, ]) const resolver = new BlockResolver(workflow) @@ -161,6 +170,8 @@ describe('BlockResolver', () => { /"invalidField" doesn't exist on block "source"/ ) expect(() => resolver.resolve('', ctx)).toThrow(/Available fields:/) + + mockGetBlockOutputs.mockReturnValue({}) }) it.concurrent('should return undefined for path in schema but missing in data', () => { @@ -298,45 +309,6 @@ describe('BlockResolver', () => { }) }) - describe('tryParseJSON', () => { - it.concurrent('should parse valid JSON object string', () => { - const resolver = new BlockResolver(createTestWorkflow()) - expect(resolver.tryParseJSON('{"key": "value"}')).toEqual({ key: 'value' }) - }) - - it.concurrent('should parse valid JSON array string', () => { - const resolver = new BlockResolver(createTestWorkflow()) - expect(resolver.tryParseJSON('[1, 2, 3]')).toEqual([1, 2, 3]) - }) - - it.concurrent('should return original value for non-string input', () => { - const resolver = new BlockResolver(createTestWorkflow()) - const obj = { key: 'value' } - expect(resolver.tryParseJSON(obj)).toBe(obj) - expect(resolver.tryParseJSON(123)).toBe(123) - expect(resolver.tryParseJSON(null)).toBe(null) - }) - - it.concurrent('should return original string for non-JSON strings', () => { - const resolver = new BlockResolver(createTestWorkflow()) - expect(resolver.tryParseJSON('plain text')).toBe('plain text') - expect(resolver.tryParseJSON('123')).toBe('123') - expect(resolver.tryParseJSON('')).toBe('') - }) - - it.concurrent('should return original string for invalid JSON', () => { - const resolver = new BlockResolver(createTestWorkflow()) - expect(resolver.tryParseJSON('{invalid json}')).toBe('{invalid json}') - expect(resolver.tryParseJSON('[1, 2,')).toBe('[1, 2,') - }) - - it.concurrent('should handle whitespace around JSON', () => { - const resolver = new BlockResolver(createTestWorkflow()) - expect(resolver.tryParseJSON(' {"key": "value"} ')).toEqual({ key: 'value' }) - expect(resolver.tryParseJSON('\n[1, 2]\n')).toEqual([1, 2]) - }) - }) - describe('Response block backwards compatibility', () => { it.concurrent('should resolve new format: ', () => { const workflow = createTestWorkflow([ diff --git a/apps/sim/executor/variables/resolvers/block.ts b/apps/sim/executor/variables/resolvers/block.ts index 09904eed53..2bdee595b1 100644 --- a/apps/sim/executor/variables/resolvers/block.ts +++ b/apps/sim/executor/variables/resolvers/block.ts @@ -1,3 +1,4 @@ +import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs' import { USER_FILE_ACCESSIBLE_PROPERTIES } from '@/lib/workflows/types' import { isReference, @@ -229,9 +230,15 @@ export class BlockResolver implements Resolver { } } + const blockType = block?.metadata?.id + const params = block?.config?.params as Record | undefined + const subBlocks = params + ? Object.fromEntries(Object.entries(params).map(([k, v]) => [k, { value: v }])) + : undefined const toolId = block?.config?.tool const toolConfig = toolId ? getTool(toolId) : undefined - const outputSchema = toolConfig?.outputs ?? block?.outputs + const outputSchema = + toolConfig?.outputs ?? (blockType ? getBlockOutputs(blockType, subBlocks) : block?.outputs) const schemaFields = getSchemaFieldNames(outputSchema) if (schemaFields.length > 0 && !isPathInOutputSchema(outputSchema, pathParts)) { throw new Error( @@ -336,21 +343,4 @@ export class BlockResolver implements Resolver { } return String(value) } - - tryParseJSON(value: any): any { - if (typeof value !== 'string') { - return value - } - - const trimmed = value.trim() - if (trimmed.length > 0 && (trimmed.startsWith('{') || trimmed.startsWith('['))) { - try { - return JSON.parse(trimmed) - } catch { - return value - } - } - - return value - } } diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index 32ccf3147f..b9ff1fa873 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -5,6 +5,7 @@ import { useShallow } from 'zustand/react/shallow' import { useSession } from '@/lib/auth/auth-client' import { useSocket } from '@/app/workspace/providers/socket-provider' import { getBlock } from '@/blocks' +import { normalizeName } from '@/executor/constants' import { useUndoRedo } from '@/hooks/use-undo-redo' import { BLOCK_OPERATIONS, @@ -23,7 +24,7 @@ import { useUndoRedoStore } from '@/stores/undo-redo' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' -import { filterNewEdges, mergeSubblockState, normalizeName } from '@/stores/workflows/utils' +import { filterNewEdges, mergeSubblockState } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { BlockState, Loop, Parallel, Position } from '@/stores/workflows/workflow/types' diff --git a/apps/sim/lib/workflows/blocks/block-outputs.ts b/apps/sim/lib/workflows/blocks/block-outputs.ts index df1b24c75a..12972141ae 100644 --- a/apps/sim/lib/workflows/blocks/block-outputs.ts +++ b/apps/sim/lib/workflows/blocks/block-outputs.ts @@ -1,4 +1,8 @@ import { createLogger } from '@sim/logger' +import { + extractFieldsFromSchema, + parseResponseFormatSafely, +} from '@/lib/core/utils/response-format' import { normalizeInputFormatValue } from '@/lib/workflows/input-format' import { classifyStartBlockType, @@ -305,6 +309,26 @@ export function getBlockOutputs( return getLegacyStarterOutputs(subBlocks) } + if (blockType === 'agent') { + const responseFormatValue = subBlocks?.responseFormat?.value + if (responseFormatValue) { + const parsed = parseResponseFormatSafely(responseFormatValue, 'agent') + if (parsed) { + const fields = extractFieldsFromSchema(parsed) + if (fields.length > 0) { + const outputs: OutputDefinition = {} + for (const field of fields) { + outputs[field.name] = { + type: (field.type || 'any') as any, + description: field.description || `Field from Agent: ${field.name}`, + } + } + return outputs + } + } + } + } + const baseOutputs = { ...(blockConfig.outputs || {}) } const filteredOutputs = filterOutputsByCondition(baseOutputs, subBlocks) return applyInputFormatToOutputs(blockType, blockConfig, subBlocks, filteredOutputs) diff --git a/apps/sim/stores/panel/variables/store.ts b/apps/sim/stores/panel/variables/store.ts index 7a4b8975e0..e2bdb21f50 100644 --- a/apps/sim/stores/panel/variables/store.ts +++ b/apps/sim/stores/panel/variables/store.ts @@ -1,11 +1,11 @@ import { createLogger } from '@sim/logger' import { create } from 'zustand' import { devtools } from 'zustand/middleware' +import { normalizeName } from '@/executor/constants' import { useOperationQueueStore } from '@/stores/operation-queue/store' import type { Variable, VariablesStore } from '@/stores/panel/variables/types' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' -import { normalizeName } from '@/stores/workflows/utils' const logger = createLogger('VariablesStore') diff --git a/apps/sim/stores/variables/store.ts b/apps/sim/stores/variables/store.ts index bf26e6fb04..df55e0b82e 100644 --- a/apps/sim/stores/variables/store.ts +++ b/apps/sim/stores/variables/store.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { v4 as uuidv4 } from 'uuid' import { create } from 'zustand' import { devtools, persist } from 'zustand/middleware' +import { normalizeName } from '@/executor/constants' import type { Variable, VariablesDimensions, @@ -11,7 +12,6 @@ import type { } from '@/stores/variables/types' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' -import { normalizeName } from '@/stores/workflows/utils' const logger = createLogger('VariablesModalStore') diff --git a/apps/sim/stores/workflows/utils.test.ts b/apps/sim/stores/workflows/utils.test.ts index 1cdbcc5d1d..a8ea48cefd 100644 --- a/apps/sim/stores/workflows/utils.test.ts +++ b/apps/sim/stores/workflows/utils.test.ts @@ -6,7 +6,8 @@ import { createStarterBlock, } from '@sim/testing' import { describe, expect, it } from 'vitest' -import { getUniqueBlockName, normalizeName } from './utils' +import { normalizeName } from '@/executor/constants' +import { getUniqueBlockName } from './utils' describe('normalizeName', () => { it.concurrent('should convert to lowercase', () => { diff --git a/apps/sim/stores/workflows/utils.ts b/apps/sim/stores/workflows/utils.ts index 6fb1f71798..df8d0d3294 100644 --- a/apps/sim/stores/workflows/utils.ts +++ b/apps/sim/stores/workflows/utils.ts @@ -17,8 +17,6 @@ import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants' const WEBHOOK_SUBBLOCK_FIELDS = ['webhookId', 'triggerPath'] -export { normalizeName } - export function filterNewEdges(edgesToAdd: Edge[], currentEdges: Edge[]): Edge[] { return edgesToAdd.filter((edge) => { if (edge.source === edge.target) return false @@ -139,7 +137,7 @@ export function prepareBlockState(options: PrepareBlockStateOptions): BlockState } } else if (subBlock.defaultValue !== undefined) { initialValue = subBlock.defaultValue - } else if (subBlock.type === 'input-format') { + } else if (subBlock.type === 'input-format' || subBlock.type === 'response-format') { initialValue = [ { id: crypto.randomUUID(), diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index b674707230..27f9716f09 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -7,14 +7,10 @@ import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs' import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { getBlock } from '@/blocks' import type { SubBlockConfig } from '@/blocks/types' +import { normalizeName } from '@/executor/constants' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' -import { - filterNewEdges, - getUniqueBlockName, - mergeSubblockState, - normalizeName, -} from '@/stores/workflows/utils' +import { filterNewEdges, getUniqueBlockName, mergeSubblockState } from '@/stores/workflows/utils' import type { Position, SubBlockState, From 004e058353a0fc59f66171b5e664230fa41db431 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 21 Jan 2026 15:30:13 -0800 Subject: [PATCH 08/18] fix(messages-input): fix cursor alignment and auto-resize with overlay (#2926) * fix(messages-input): fix cursor alignment and auto-resize with overlay * fixed remaining zustand warnings --- .../components/combobox/combobox.tsx | 4 +- .../components/dropdown/dropdown.tsx | 4 +- .../messages-input/messages-input.tsx | 196 +++++++++++------- .../sub-block/hooks/use-depends-on-gate.ts | 3 +- .../sub-block/hooks/use-sub-block-value.ts | 7 +- .../panel/components/editor/editor.tsx | 4 +- .../workflow-block/workflow-block.tsx | 7 +- .../w/components/preview/preview.tsx | 5 +- 8 files changed, 148 insertions(+), 82 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index c86e934231..ea922b3baf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -1,6 +1,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { isEqual } from 'lodash' import { useReactFlow } from 'reactflow' +import { useStoreWithEqualityFn } from 'zustand/traditional' import { Combobox, type ComboboxOption } from '@/components/emcn/components' import { cn } from '@/lib/core/utils/cn' import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' @@ -102,7 +103,8 @@ export const ComboBox = memo(function ComboBox({ [blockConfig?.subBlocks] ) const canonicalModeOverrides = blockState?.data?.canonicalModes - const dependencyValues = useSubBlockStore( + const dependencyValues = useStoreWithEqualityFn( + useSubBlockStore, useCallback( (state) => { if (dependsOnFields.length === 0 || !activeWorkflowId) return [] diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index 51aa596a5b..d8d3ec00ec 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -1,5 +1,6 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { isEqual } from 'lodash' +import { useStoreWithEqualityFn } from 'zustand/traditional' import { Badge } from '@/components/emcn' import { Combobox, type ComboboxOption } from '@/components/emcn/components' import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' @@ -100,7 +101,8 @@ export const Dropdown = memo(function Dropdown({ [blockConfig?.subBlocks] ) const canonicalModeOverrides = blockState?.data?.canonicalModes - const dependencyValues = useSubBlockStore( + const dependencyValues = useStoreWithEqualityFn( + useSubBlockStore, useCallback( (state) => { if (dependsOnFields.length === 0 || !activeWorkflowId) return [] diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx index 6ada0e47cb..30b3fa2e94 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx @@ -1,4 +1,12 @@ -import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' +import { + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' import { isEqual } from 'lodash' import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react' import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn' @@ -382,93 +390,138 @@ export function MessagesInput({ textareaRefs.current[fieldId]?.focus() }, []) - const autoResizeTextarea = useCallback((fieldId: string) => { + const syncOverlay = useCallback((fieldId: string) => { const textarea = textareaRefs.current[fieldId] - if (!textarea) return const overlay = overlayRefs.current[fieldId] + if (!textarea || !overlay) return - // If user has manually resized, respect their chosen height and only sync overlay. - if (userResizedRef.current[fieldId]) { - const currentHeight = - textarea.offsetHeight || Number.parseFloat(textarea.style.height) || MIN_TEXTAREA_HEIGHT_PX - const clampedHeight = Math.max(MIN_TEXTAREA_HEIGHT_PX, currentHeight) - textarea.style.height = `${clampedHeight}px` + overlay.style.width = `${textarea.clientWidth}px` + overlay.scrollTop = textarea.scrollTop + overlay.scrollLeft = textarea.scrollLeft + }, []) + + const autoResizeTextarea = useCallback( + (fieldId: string) => { + const textarea = textareaRefs.current[fieldId] + const overlay = overlayRefs.current[fieldId] + if (!textarea) return + + if (!textarea.value.trim()) { + userResizedRef.current[fieldId] = false + } + + if (userResizedRef.current[fieldId]) { + if (overlay) { + overlay.style.height = `${textarea.offsetHeight}px` + } + syncOverlay(fieldId) + return + } + + textarea.style.height = 'auto' + const scrollHeight = textarea.scrollHeight + const height = Math.min( + MAX_TEXTAREA_HEIGHT_PX, + Math.max(MIN_TEXTAREA_HEIGHT_PX, scrollHeight) + ) + + textarea.style.height = `${height}px` if (overlay) { - overlay.style.height = `${clampedHeight}px` + overlay.style.height = `${height}px` } - return - } - textarea.style.height = 'auto' - const naturalHeight = textarea.scrollHeight || MIN_TEXTAREA_HEIGHT_PX - const nextHeight = Math.min( - MAX_TEXTAREA_HEIGHT_PX, - Math.max(MIN_TEXTAREA_HEIGHT_PX, naturalHeight) - ) - textarea.style.height = `${nextHeight}px` + syncOverlay(fieldId) + }, + [syncOverlay] + ) - if (overlay) { - overlay.style.height = `${nextHeight}px` - } - }, []) + const handleResizeStart = useCallback( + (fieldId: string, e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() - const handleResizeStart = useCallback((fieldId: string, e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() + const textarea = textareaRefs.current[fieldId] + if (!textarea) return - const textarea = textareaRefs.current[fieldId] - if (!textarea) return + const startHeight = textarea.offsetHeight || textarea.scrollHeight || MIN_TEXTAREA_HEIGHT_PX - const startHeight = textarea.offsetHeight || textarea.scrollHeight || MIN_TEXTAREA_HEIGHT_PX + isResizingRef.current = true + resizeStateRef.current = { + fieldId, + startY: e.clientY, + startHeight, + } - isResizingRef.current = true - resizeStateRef.current = { - fieldId, - startY: e.clientY, - startHeight, - } + const handleMouseMove = (moveEvent: MouseEvent) => { + if (!isResizingRef.current || !resizeStateRef.current) return - const handleMouseMove = (moveEvent: MouseEvent) => { - if (!isResizingRef.current || !resizeStateRef.current) return + const { fieldId: activeFieldId, startY, startHeight } = resizeStateRef.current + const deltaY = moveEvent.clientY - startY + const nextHeight = Math.max(MIN_TEXTAREA_HEIGHT_PX, startHeight + deltaY) - const { fieldId: activeFieldId, startY, startHeight } = resizeStateRef.current - const deltaY = moveEvent.clientY - startY - const nextHeight = Math.max(MIN_TEXTAREA_HEIGHT_PX, startHeight + deltaY) + const activeTextarea = textareaRefs.current[activeFieldId] + const overlay = overlayRefs.current[activeFieldId] - const activeTextarea = textareaRefs.current[activeFieldId] - if (activeTextarea) { - activeTextarea.style.height = `${nextHeight}px` - } + if (activeTextarea) { + activeTextarea.style.height = `${nextHeight}px` + } - const overlay = overlayRefs.current[activeFieldId] - if (overlay) { - overlay.style.height = `${nextHeight}px` + if (overlay) { + overlay.style.height = `${nextHeight}px` + if (activeTextarea) { + overlay.scrollTop = activeTextarea.scrollTop + overlay.scrollLeft = activeTextarea.scrollLeft + } + } } - } - const handleMouseUp = () => { - if (resizeStateRef.current) { - const { fieldId: activeFieldId } = resizeStateRef.current - userResizedRef.current[activeFieldId] = true - } + const handleMouseUp = () => { + if (resizeStateRef.current) { + const { fieldId: activeFieldId } = resizeStateRef.current + userResizedRef.current[activeFieldId] = true + syncOverlay(activeFieldId) + } - isResizingRef.current = false - resizeStateRef.current = null - document.removeEventListener('mousemove', handleMouseMove) - document.removeEventListener('mouseup', handleMouseUp) - } + isResizingRef.current = false + resizeStateRef.current = null + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } - document.addEventListener('mousemove', handleMouseMove) - document.addEventListener('mouseup', handleMouseUp) - }, []) + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + }, + [syncOverlay] + ) - useEffect(() => { + useLayoutEffect(() => { currentMessages.forEach((_, index) => { - const fieldId = `message-${index}` - autoResizeTextarea(fieldId) + autoResizeTextarea(`message-${index}`) }) }, [currentMessages, autoResizeTextarea]) + useEffect(() => { + const observers: ResizeObserver[] = [] + + for (let i = 0; i < currentMessages.length; i++) { + const fieldId = `message-${i}` + const textarea = textareaRefs.current[fieldId] + const overlay = overlayRefs.current[fieldId] + + if (textarea && overlay) { + const observer = new ResizeObserver(() => { + overlay.style.width = `${textarea.clientWidth}px` + }) + observer.observe(textarea) + observers.push(observer) + } + } + + return () => { + observers.forEach((observer) => observer.disconnect()) + } + }, [currentMessages.length]) + return (
{currentMessages.map((message, index) => ( @@ -621,19 +674,15 @@ export function MessagesInput({
{/* Content Input with overlay for variable highlighting */} -
+