diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index c2094755a9..c58b102bc5 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -2,10 +2,9 @@ import { useEffect, useState } from 'react' import { createLogger } from '@sim/logger' -import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react' +import { Eye, EyeOff } from 'lucide-react' import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' -import { Button } from '@/components/ui/button' import { Dialog, DialogContent, @@ -22,8 +21,10 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { inter } from '@/app/_styles/fonts/inter/inter' import { soehne } from '@/app/_styles/fonts/soehne/soehne' +import { BrandedButton } from '@/app/(auth)/components/branded-button' 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('LoginForm') @@ -105,8 +106,7 @@ export default function LoginPage({ const [password, setPassword] = useState('') const [passwordErrors, setPasswordErrors] = useState([]) const [showValidationError, setShowValidationError] = useState(false) - const [buttonClass, setButtonClass] = useState('branded-button-gradient') - const [isButtonHovered, setIsButtonHovered] = useState(false) + const buttonClass = useBrandedButtonClass() const [callbackUrl, setCallbackUrl] = useState('/workspace') const [isInviteFlow, setIsInviteFlow] = useState(false) @@ -114,7 +114,6 @@ export default function LoginPage({ const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false) const [forgotPasswordEmail, setForgotPasswordEmail] = useState('') const [isSubmittingReset, setIsSubmittingReset] = useState(false) - const [isResetButtonHovered, setIsResetButtonHovered] = useState(false) const [resetStatus, setResetStatus] = useState<{ type: 'success' | 'error' | null message: string @@ -123,6 +122,7 @@ export default function LoginPage({ const [email, setEmail] = useState('') const [emailErrors, setEmailErrors] = useState([]) const [showEmailValidationError, setShowEmailValidationError] = useState(false) + const [resetSuccessMessage, setResetSuccessMessage] = useState(null) useEffect(() => { setMounted(true) @@ -139,32 +139,12 @@ export default function LoginPage({ const inviteFlow = searchParams.get('invite_flow') === 'true' setIsInviteFlow(inviteFlow) - } - - 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') + const resetSuccess = searchParams.get('resetSuccess') === 'true' + if (resetSuccess) { + setResetSuccessMessage('Password reset successful. Please sign in with your new password.') } } - - 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]) useEffect(() => { @@ -202,6 +182,13 @@ export default function LoginPage({ e.preventDefault() setIsLoading(true) + const redirectToVerify = (emailToVerify: string) => { + if (typeof window !== 'undefined') { + sessionStorage.setItem('verificationEmail', emailToVerify) + } + router.push('/verify') + } + const formData = new FormData(e.currentTarget) const emailRaw = formData.get('email') as string const email = emailRaw.trim().toLowerCase() @@ -221,6 +208,7 @@ export default function LoginPage({ try { const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/workspace' + let errorHandled = false const result = await client.signIn.email( { @@ -231,11 +219,16 @@ export default function LoginPage({ { onError: (ctx) => { logger.error('Login error:', ctx.error) - const errorMessage: string[] = ['Invalid email or password'] if (ctx.error.code?.includes('EMAIL_NOT_VERIFIED')) { + errorHandled = true + redirectToVerify(email) return } + + errorHandled = true + const errorMessage: string[] = ['Invalid email or password'] + if ( ctx.error.code?.includes('BAD_REQUEST') || ctx.error.message?.includes('Email and password sign in is not enabled') @@ -271,6 +264,7 @@ export default function LoginPage({ errorMessage.push('Too many requests. Please wait a moment before trying again.') } + setResetSuccessMessage(null) setPasswordErrors(errorMessage) setShowValidationError(true) }, @@ -278,15 +272,25 @@ export default function LoginPage({ ) if (!result || result.error) { + // Show error if not already handled by onError callback + if (!errorHandled) { + setResetSuccessMessage(null) + const errorMessage = result?.error?.message || 'Login failed. Please try again.' + setPasswordErrors([errorMessage]) + setShowValidationError(true) + } setIsLoading(false) return } + + // Clear reset success message on successful login + setResetSuccessMessage(null) + + // Explicit redirect fallback if better-auth doesn't redirect + router.push(safeCallbackUrl) } catch (err: any) { if (err.message?.includes('not verified') || err.code?.includes('EMAIL_NOT_VERIFIED')) { - if (typeof window !== 'undefined') { - sessionStorage.setItem('verificationEmail', email) - } - router.push('/verify') + redirectToVerify(email) return } @@ -400,6 +404,13 @@ export default function LoginPage({ )} + {/* Password reset success message */} + {resetSuccessMessage && ( +
+

{resetSuccessMessage}

+
+ )} + {/* Email/Password Form - show unless explicitly disabled */} {!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && (
@@ -482,24 +493,14 @@ export default function LoginPage({ - + Sign in +
)} @@ -610,25 +611,15 @@ export default function LoginPage({

{resetStatus.message}

)} - + Send Reset Link + 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..4bc30dba68 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,12 @@ 'use client' -import { useEffect, useState } from 'react' -import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react' -import { Button } from '@/components/ui/button' +import { useState } from 'react' +import { Eye, EyeOff } from 'lucide-react' 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 { BrandedButton } from '@/app/(auth)/components/branded-button' interface RequestResetFormProps { email: string @@ -27,36 +27,6 @@ export function RequestResetForm({ statusMessage, className, }: RequestResetFormProps) { - const [buttonClass, setButtonClass] = useState('branded-button-gradient') - 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) @@ -94,24 +64,14 @@ export function RequestResetForm({ )} - + Send Reset Link + ) } @@ -138,35 +98,6 @@ 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 [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() @@ -296,24 +227,14 @@ export function SetNewPasswordForm({ )} - + Reset Password + ) } diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 5aeb59fa67..bab806f23a 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -2,10 +2,9 @@ import { Suspense, useEffect, useState } from 'react' import { createLogger } from '@sim/logger' -import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react' +import { Eye, EyeOff } from 'lucide-react' import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' -import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { client, useSession } from '@/lib/auth/auth-client' @@ -14,8 +13,10 @@ 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 { BrandedButton } from '@/app/(auth)/components/branded-button' 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,8 +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 [isButtonHovered, setIsButtonHovered] = useState(false) + const buttonClass = useBrandedButtonClass() const [name, setName] = useState('') const [nameErrors, setNameErrors] = useState([]) @@ -126,31 +126,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[] => { @@ -500,24 +475,14 @@ function SignupFormContent({ - + Create account + )} 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/(landing)/careers/page.tsx b/apps/sim/app/(landing)/careers/page.tsx index d756661aa1..7bd88b4932 100644 --- a/apps/sim/app/(landing)/careers/page.tsx +++ b/apps/sim/app/(landing)/careers/page.tsx @@ -4,7 +4,6 @@ import { useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { X } from 'lucide-react' import { Textarea } from '@/components/emcn' -import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { @@ -18,6 +17,7 @@ import { isHosted } from '@/lib/core/config/feature-flags' import { cn } from '@/lib/core/utils/cn' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { soehne } from '@/app/_styles/fonts/soehne/soehne' +import { BrandedButton } from '@/app/(auth)/components/branded-button' import Footer from '@/app/(landing)/components/footer/footer' import Nav from '@/app/(landing)/components/nav/nav' @@ -493,18 +493,17 @@ export default function CareersPage() { {/* Submit Button */}
- + {submitStatus === 'success' ? 'Submitted' : 'Submit Application'} +
diff --git a/apps/sim/app/(landing)/components/nav/nav.tsx b/apps/sim/app/(landing)/components/nav/nav.tsx index 0478a69a12..ff9e8787ec 100644 --- a/apps/sim/app/(landing)/components/nav/nav.tsx +++ b/apps/sim/app/(landing)/components/nav/nav.tsx @@ -11,6 +11,7 @@ import { useBrandConfig } from '@/lib/branding/branding' import { isHosted } from '@/lib/core/config/feature-flags' import { soehne } from '@/app/_styles/fonts/soehne/soehne' import { getFormattedGitHubStars } from '@/app/(landing)/actions/github' +import { useBrandedButtonClass } from '@/hooks/use-branded-button-class' const logger = createLogger('nav') @@ -20,11 +21,12 @@ interface NavProps { } export default function Nav({ hideAuthButtons = false, variant = 'landing' }: NavProps = {}) { - const [githubStars, setGithubStars] = useState('25.1k') + const [githubStars, setGithubStars] = useState('25.8k') const [isHovered, setIsHovered] = useState(false) const [isLoginHovered, setIsLoginHovered] = useState(false) const router = useRouter() const brand = useBrandConfig() + const buttonClass = useBrandedButtonClass() useEffect(() => { if (variant !== 'landing') return @@ -183,7 +185,7 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na href='/signup' onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} - className='group inline-flex items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[14px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all sm:text-[16px]' + className={`${buttonClass} group inline-flex items-center justify-center gap-2 rounded-[10px] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white transition-all`} aria-label='Get started with Sim - Sign up for free' prefetch={true} > diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index 08dd16fdff..9fe7d8510e 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -4,6 +4,11 @@ import { createLogger } from '@sim/logger' import { and, desc, eq, inArray } from 'drizzle-orm' import { getSession } from '@/lib/auth' import { refreshOAuthToken } from '@/lib/oauth' +import { + getMicrosoftRefreshTokenExpiry, + isMicrosoftProvider, + PROACTIVE_REFRESH_THRESHOLD_DAYS, +} from '@/lib/oauth/microsoft' const logger = createLogger('OAuthUtilsAPI') @@ -205,15 +210,32 @@ export async function refreshAccessTokenIfNeeded( } // Decide if we should refresh: token missing OR expired - const expiresAt = credential.accessTokenExpiresAt + const accessTokenExpiresAt = credential.accessTokenExpiresAt + const refreshTokenExpiresAt = credential.refreshTokenExpiresAt const now = new Date() - const shouldRefresh = - !!credential.refreshToken && (!credential.accessToken || (expiresAt && expiresAt <= now)) + + // Check if access token needs refresh (missing or expired) + const accessTokenNeedsRefresh = + !!credential.refreshToken && + (!credential.accessToken || (accessTokenExpiresAt && accessTokenExpiresAt <= now)) + + // Check if we should proactively refresh to prevent refresh token expiry + // This applies to Microsoft providers whose refresh tokens expire after 90 days of inactivity + const proactiveRefreshThreshold = new Date( + now.getTime() + PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000 + ) + const refreshTokenNeedsProactiveRefresh = + !!credential.refreshToken && + isMicrosoftProvider(credential.providerId) && + refreshTokenExpiresAt && + refreshTokenExpiresAt <= proactiveRefreshThreshold + + const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh const accessToken = credential.accessToken if (shouldRefresh) { - logger.info(`[${requestId}] Token expired, attempting to refresh for credential`) + logger.info(`[${requestId}] Refreshing token for credential`) try { const refreshedToken = await refreshOAuthToken( credential.providerId, @@ -227,11 +249,15 @@ export async function refreshAccessTokenIfNeeded( userId: credential.userId, hasRefreshToken: !!credential.refreshToken, }) + if (!accessTokenNeedsRefresh && accessToken) { + logger.info(`[${requestId}] Proactive refresh failed but access token still valid`) + return accessToken + } return null } // Prepare update data - const updateData: any = { + const updateData: Record = { accessToken: refreshedToken.accessToken, accessTokenExpiresAt: new Date(Date.now() + refreshedToken.expiresIn * 1000), updatedAt: new Date(), @@ -243,6 +269,10 @@ export async function refreshAccessTokenIfNeeded( updateData.refreshToken = refreshedToken.refreshToken } + if (isMicrosoftProvider(credential.providerId)) { + updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry() + } + // Update the token in the database await db.update(account).set(updateData).where(eq(account.id, credentialId)) @@ -256,6 +286,10 @@ export async function refreshAccessTokenIfNeeded( credentialId, userId: credential.userId, }) + if (!accessTokenNeedsRefresh && accessToken) { + logger.info(`[${requestId}] Proactive refresh failed but access token still valid`) + return accessToken + } return null } } else if (!accessToken) { @@ -277,10 +311,27 @@ export async function refreshTokenIfNeeded( credentialId: string ): Promise<{ accessToken: string; refreshed: boolean }> { // Decide if we should refresh: token missing OR expired - const expiresAt = credential.accessTokenExpiresAt + const accessTokenExpiresAt = credential.accessTokenExpiresAt + const refreshTokenExpiresAt = credential.refreshTokenExpiresAt const now = new Date() - const shouldRefresh = - !!credential.refreshToken && (!credential.accessToken || (expiresAt && expiresAt <= now)) + + // Check if access token needs refresh (missing or expired) + const accessTokenNeedsRefresh = + !!credential.refreshToken && + (!credential.accessToken || (accessTokenExpiresAt && accessTokenExpiresAt <= now)) + + // Check if we should proactively refresh to prevent refresh token expiry + // This applies to Microsoft providers whose refresh tokens expire after 90 days of inactivity + const proactiveRefreshThreshold = new Date( + now.getTime() + PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000 + ) + const refreshTokenNeedsProactiveRefresh = + !!credential.refreshToken && + isMicrosoftProvider(credential.providerId) && + refreshTokenExpiresAt && + refreshTokenExpiresAt <= proactiveRefreshThreshold + + const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh // If token appears valid and present, return it directly if (!shouldRefresh) { @@ -293,13 +344,17 @@ export async function refreshTokenIfNeeded( if (!refreshResult) { logger.error(`[${requestId}] Failed to refresh token for credential`) + if (!accessTokenNeedsRefresh && credential.accessToken) { + logger.info(`[${requestId}] Proactive refresh failed but access token still valid`) + return { accessToken: credential.accessToken, refreshed: false } + } throw new Error('Failed to refresh token') } const { accessToken: refreshedToken, expiresIn, refreshToken: newRefreshToken } = refreshResult // Prepare update data - const updateData: any = { + const updateData: Record = { accessToken: refreshedToken, accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000), // Use provider's expiry updatedAt: new Date(), @@ -311,6 +366,10 @@ export async function refreshTokenIfNeeded( updateData.refreshToken = newRefreshToken } + if (isMicrosoftProvider(credential.providerId)) { + updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry() + } + await db.update(account).set(updateData).where(eq(account.id, credentialId)) logger.info(`[${requestId}] Successfully refreshed access token`) @@ -331,6 +390,11 @@ export async function refreshTokenIfNeeded( } } + if (!accessTokenNeedsRefresh && credential.accessToken) { + logger.info(`[${requestId}] Proactive refresh failed but access token still valid`) + return { accessToken: credential.accessToken, refreshed: false } + } + logger.error(`[${requestId}] Refresh failed and no valid token found in DB`, error) throw error } 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) { 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/changelog/components/branded-link.tsx b/apps/sim/app/changelog/components/branded-link.tsx new file mode 100644 index 0000000000..f126c320ac --- /dev/null +++ b/apps/sim/app/changelog/components/branded-link.tsx @@ -0,0 +1,27 @@ +'use client' + +import Link from 'next/link' +import { useBrandedButtonClass } from '@/hooks/use-branded-button-class' + +interface BrandedLinkProps { + href: string + children: React.ReactNode + className?: string + target?: string + rel?: string +} + +export function BrandedLink({ href, children, className = '', target, rel }: BrandedLinkProps) { + const buttonClass = useBrandedButtonClass() + + return ( + + {children} + + ) +} diff --git a/apps/sim/app/changelog/components/changelog-content.tsx b/apps/sim/app/changelog/components/changelog-content.tsx index 36c817e7f1..e0eefc3324 100644 --- a/apps/sim/app/changelog/components/changelog-content.tsx +++ b/apps/sim/app/changelog/components/changelog-content.tsx @@ -2,6 +2,7 @@ import { BookOpen, Github, Rss } from 'lucide-react' import Link from 'next/link' import { inter } from '@/app/_styles/fonts/inter/inter' import { soehne } from '@/app/_styles/fonts/soehne/soehne' +import { BrandedLink } from '@/app/changelog/components/branded-link' import ChangelogList from '@/app/changelog/components/timeline-list' export interface ChangelogEntry { @@ -66,25 +67,24 @@ export default async function ChangelogContent() {
- View on GitHub - + Documentation RSS Feed diff --git a/apps/sim/app/chat/[identifier]/chat.tsx b/apps/sim/app/chat/[identifier]/chat.tsx index 926f96a064..94082ffec2 100644 --- a/apps/sim/app/chat/[identifier]/chat.tsx +++ b/apps/sim/app/chat/[identifier]/chat.tsx @@ -117,7 +117,7 @@ export default function ChatClient({ identifier }: { identifier: string }) { const [error, setError] = useState(null) const messagesEndRef = useRef(null) const messagesContainerRef = useRef(null) - const [starCount, setStarCount] = useState('25.1k') + const [starCount, setStarCount] = useState('25.8k') const [conversationId, setConversationId] = useState('') const [showScrollButton, setShowScrollButton] = useState(false) diff --git a/apps/sim/app/templates/components/template-card.tsx b/apps/sim/app/templates/components/template-card.tsx index 3c1c32a414..0be5079e0a 100644 --- a/apps/sim/app/templates/components/template-card.tsx +++ b/apps/sim/app/templates/components/template-card.tsx @@ -207,7 +207,6 @@ function TemplateCardInner({ isPannable={false} defaultZoom={0.8} fitPadding={0.2} - lightweight /> ) : (
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx index 49d0e316c5..a0f2a73764 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx @@ -16,8 +16,8 @@ import { import { redactApiKeys } from '@/lib/core/security/redaction' import { cn } from '@/lib/core/utils/cn' import { - BlockDetailsSidebar, getLeftmostBlockId, + PreviewEditor, WorkflowPreview, } from '@/app/workspace/[workspaceId]/w/components/preview' import { useExecutionSnapshot } from '@/hooks/queries/logs' @@ -248,11 +248,10 @@ export function ExecutionSnapshot({ cursorStyle='pointer' executedBlocks={blockExecutions} selectedBlockId={pinnedBlockId} - lightweight />
{pinnedBlockId && workflowState.blocks[pinnedBlockId] && ( - ) : ( 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( 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 && (
{expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && ( - ((_, ref) => { isPannable={false} defaultZoom={0.8} fitPadding={0.2} - lightweight />
) 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/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/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/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/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/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts index 4cc36a67db..0cfc45369b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts @@ -34,3 +34,4 @@ export { Text } from './text/text' export { TimeInput } from './time-input/time-input' export { ToolInput } from './tool-input/tool-input' export { VariablesInput } from './variables-input/variables-input' +export { WorkflowSelectorInput } from './workflow-selector/workflow-selector-input' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/input-mapping/input-mapping.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/input-mapping/input-mapping.tsx index 79555d90aa..16d1f7f5a9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/input-mapping/input-mapping.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/input-mapping/input-mapping.tsx @@ -2,12 +2,13 @@ import { useMemo, useRef, useState } from 'react' import { Badge, Input } from '@/components/emcn' import { Label } from '@/components/ui/label' import { cn } from '@/lib/core/utils/cn' +import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input' 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 { useWorkflowInputFields } from '@/hooks/queries/workflows' +import { useWorkflowState } from '@/hooks/queries/workflows' /** * Props for the InputMappingField component @@ -70,7 +71,11 @@ export function InputMapping({ const overlayRefs = useRef>(new Map()) const workflowId = typeof selectedWorkflowId === 'string' ? selectedWorkflowId : undefined - const { data: childInputFields = [], isLoading } = useWorkflowInputFields(workflowId) + const { data: workflowState, isLoading } = useWorkflowState(workflowId) + const childInputFields = useMemo( + () => (workflowState?.blocks ? extractInputFieldsFromBlocks(workflowState.blocks) : []), + [workflowState?.blocks] + ) const [collapsedFields, setCollapsedFields] = useState>({}) const valueObj: Record = useMemo(() => { 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 */} -
+