Compare commits

..

33 Commits

Author SHA1 Message Date
Vikhyath Mondreti
45371e521e v0.5.66: external http requests fix, ring highlighting 2026-01-21 02:55:39 -08:00
Waleed
0ce0f98aa5 v0.5.65: gemini updates, textract integration, ui updates (#2909)
* fix(google): wrap primitive tool responses for Gemini API compatibility (#2900)

* fix(canonical): copilot path + update parent (#2901)

* fix(rss): add top-level title, link, pubDate fields to RSS trigger output (#2902)

* fix(rss): add top-level title, link, pubDate fields to RSS trigger output

* fix(imap): add top-level fields to IMAP trigger output

* improvement(browseruse): add profile id param (#2903)

* improvement(browseruse): add profile id param

* make request a stub since we have directExec

* improvement(executor): upgraded abort controller to handle aborts for loops and parallels (#2880)

* improvement(executor): upgraded abort controller to handle aborts for loops and parallels

* comments

* improvement(files): update execution for passing base64 strings (#2906)

* progress

* improvement(execution): update execution for passing base64 strings

* fix types

* cleanup comments

* path security vuln

* reject promise correctly

* fix redirect case

* remove proxy routes

* fix tests

* use ipaddr

* feat(tools): added textract, added v2 for mistral, updated tag dropdown (#2904)

* feat(tools): added textract

* cleanup

* ack pr comments

* reorder

* removed upload for textract async version

* fix additional fields dropdown in editor, update parser to leave validation to be done on the server

* added mistral v2, files v2, and finalized textract

* updated the rest of the old file patterns, updated mistral outputs for v2

* updated tag dropdown to parse non-operation fields as well

* updated extension finder

* cleanup

* added description for inputs to workflow

* use helper for internal route check

* fix tag dropdown merge conflict change

* remove duplicate code

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>

* fix(ui): change add inputs button to match output selector (#2907)

* fix(canvas): removed invite to workspace from canvas popover (#2908)

* fix(canvas): removed invite to workspace

* removed unused props

* fix(copilot): legacy tool display names (#2911)

* fix(a2a): canonical merge  (#2912)

* fix canonical merge

* fix empty array case

* fix(change-detection): copilot diffs have extra field (#2913)

* improvement(logs): improved logs ui bugs, added subflow disable UI (#2910)

* improvement(logs): improved logs ui bugs, added subflow disable UI

* added duplicate to action bar for subflows

* feat(broadcast): email v0.5 (#2905)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2026-01-20 23:54:55 -08:00
Waleed
dff1c9d083 v0.5.64: unsubscribe, search improvements, metrics, additional SSO configuration 2026-01-20 00:34:11 -08:00
Vikhyath Mondreti
b09f683072 v0.5.63: ui and performance improvements, more google tools 2026-01-18 15:22:42 -08:00
Vikhyath Mondreti
a8bb0db660 v0.5.62: webhook bug fixes, seeding default subblock values, block selection fixes 2026-01-16 20:27:06 -08:00
Waleed
af82820a28 v0.5.61: webhook improvements, workflow controls, react query for deployment status, chat fixes, reducto and pulse OCR, linear fixes 2026-01-16 18:06:23 -08:00
Waleed
4372841797 v0.5.60: invitation flow improvements, chat fixes, a2a improvements, additional copilot actions 2026-01-15 00:02:18 -08:00
Waleed
5e8c843241 v0.5.59: a2a support, documentation 2026-01-13 13:21:21 -08:00
Waleed
7bf3d73ee6 v0.5.58: export folders, new tools, permissions groups enhancements 2026-01-13 00:56:59 -08:00
Vikhyath Mondreti
7ffc11a738 v0.5.57: subagents, context menu improvements, bug fixes 2026-01-11 11:38:40 -08:00
Waleed
be578e2ed7 v0.5.56: batch operations, access control and permission groups, billing fixes 2026-01-10 00:31:34 -08:00
Waleed
f415e5edc4 v0.5.55: polling groups, bedrock provider, devcontainer fixes, workflow preview enhancements 2026-01-08 23:36:56 -08:00
Waleed
13a6e6c3fa v0.5.54: seo, model blacklist, helm chart updates, fireflies integration, autoconnect improvements, billing fixes 2026-01-07 16:09:45 -08:00
Waleed
f5ab7f21ae v0.5.53: hotkey improvements, added redis fallback, fixes for workflow tool 2026-01-06 23:34:52 -08:00
Waleed
bfb6fffe38 v0.5.52: new port-based router block, combobox expression and variable support 2026-01-06 16:14:10 -08:00
Waleed
4fbec0a43f v0.5.51: triggers, kb, condition block improvements, supabase and grain integration updates 2026-01-06 14:26:46 -08:00
Waleed
585f5e365b v0.5.50: import improvements, ui upgrades, kb styling and performance improvements 2026-01-05 00:35:55 -08:00
Waleed
3792bdd252 v0.5.49: hitl improvements, new email styles, imap trigger, logs context menu (#2672)
* feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659)

* feat(email): welcome email; improvement(emails): ui/ux (#2658)

* feat(email): welcome email; improvement(emails): ui/ux

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

Co-authored-by: waleed <walif6@gmail.com>

* fix(logging): hitl + trigger dev crash protection (#2664)

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

* feat(imap): added support for imap trigger (#2663)

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

* feat(i18n): update translations (#2665)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(grain): updated grain trigger to auto-establish trigger (#2666)

Co-authored-by: aadamgough <adam@sim.ai>

* feat(admin): routes to manage deployments (#2667)

* feat(admin): routes to manage deployments

* fix naming fo deployed by

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date (#2668)

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date

* removed unused params, cleaned up redundant utils

* improvement(invite): aligned styling (#2669)

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: aadamgough <adam@sim.ai>
2026-01-03 13:19:18 -08:00
Waleed
eb5d1f3e5b v0.5.48: copy-paste workflow blocks, docs updates, mcp tool fixes 2025-12-31 18:00:04 -08:00
Waleed
54ab82c8dd v0.5.47: deploy workflow as mcp, kb chunks tokenizer, UI improvements, jira service management tools 2025-12-30 23:18:58 -08:00
Waleed
f895bf469b v0.5.46: build improvements, greptile, light mode improvements 2025-12-29 02:17:52 -08:00
Waleed
dd3209af06 v0.5.45: light mode fixes, realtime usage indicator, docker build improvements 2025-12-27 19:57:42 -08:00
Waleed
b6ba3b50a7 v0.5.44: keyboard shortcuts, autolayout, light mode, byok, testing improvements 2025-12-26 21:25:19 -08:00
Waleed
b304233062 v0.5.43: export logs, circleback, grain, vertex, code hygiene, schedule improvements 2025-12-23 19:19:18 -08:00
Vikhyath Mondreti
57e4b49bd6 v0.5.42: fix memory migration 2025-12-23 01:24:54 -08:00
Vikhyath Mondreti
e12dd204ed v0.5.41: memory fixes, copilot improvements, knowledgebase improvements, LLM providers standardization 2025-12-23 00:15:18 -08:00
Vikhyath Mondreti
3d9d9cbc54 v0.5.40: supabase ops to allow non-public schemas, jira uuid 2025-12-21 22:28:05 -08:00
Waleed
0f4ec962ad v0.5.39: notion, workflow variables fixes 2025-12-20 20:44:00 -08:00
Waleed
4827866f9a v0.5.38: snap to grid, copilot ux improvements, billing line items 2025-12-20 17:24:38 -08:00
Waleed
3e697d9ed9 v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types 2025-12-19 22:31:55 -08:00
Martin Yankov
4431a1a484 fix(helm): add custom egress rules to realtime network policy (#2481)
The realtime service network policy was missing the custom egress rules section
that allows configuration of additional egress rules via values.yaml. This caused
the realtime pods to be unable to connect to external databases (e.g., PostgreSQL
on port 5432) when using external database configurations.

The app network policy already had this section, but the realtime network policy
was missing it, creating an inconsistency and preventing the realtime service
from accessing external databases configured via networkPolicy.egress values.

This fix adds the same custom egress rules template section to the realtime
network policy, matching the app network policy behavior and allowing users to
configure database connectivity via values.yaml.
2025-12-19 18:59:08 -08:00
Waleed
4d1a9a3f22 v0.5.36: hitl improvements, opengraph, slack fixes, one-click unsubscribe, auth checks, new db indexes 2025-12-19 01:27:49 -08:00
Vikhyath Mondreti
eb07a080fb v0.5.35: helm updates, copilot improvements, 404 for docs, salesforce fixes, subflow resize clamping 2025-12-18 16:23:19 -08:00
89 changed files with 1412 additions and 1522 deletions

View File

@@ -2,9 +2,10 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { Eye, EyeOff } from 'lucide-react' import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -21,10 +22,8 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
import { quickValidateEmail } from '@/lib/messaging/email/validation' import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { inter } from '@/app/_styles/fonts/inter/inter' import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne' 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 { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button' import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
const logger = createLogger('LoginForm') const logger = createLogger('LoginForm')
@@ -106,7 +105,8 @@ export default function LoginPage({
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [passwordErrors, setPasswordErrors] = useState<string[]>([]) const [passwordErrors, setPasswordErrors] = useState<string[]>([])
const [showValidationError, setShowValidationError] = useState(false) const [showValidationError, setShowValidationError] = useState(false)
const buttonClass = useBrandedButtonClass() const [buttonClass, setButtonClass] = useState('branded-button-gradient')
const [isButtonHovered, setIsButtonHovered] = useState(false)
const [callbackUrl, setCallbackUrl] = useState('/workspace') const [callbackUrl, setCallbackUrl] = useState('/workspace')
const [isInviteFlow, setIsInviteFlow] = useState(false) const [isInviteFlow, setIsInviteFlow] = useState(false)
@@ -114,6 +114,7 @@ export default function LoginPage({
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false) const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('') const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
const [isSubmittingReset, setIsSubmittingReset] = useState(false) const [isSubmittingReset, setIsSubmittingReset] = useState(false)
const [isResetButtonHovered, setIsResetButtonHovered] = useState(false)
const [resetStatus, setResetStatus] = useState<{ const [resetStatus, setResetStatus] = useState<{
type: 'success' | 'error' | null type: 'success' | 'error' | null
message: string message: string
@@ -122,7 +123,6 @@ export default function LoginPage({
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [emailErrors, setEmailErrors] = useState<string[]>([]) const [emailErrors, setEmailErrors] = useState<string[]>([])
const [showEmailValidationError, setShowEmailValidationError] = useState(false) const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [resetSuccessMessage, setResetSuccessMessage] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
setMounted(true) setMounted(true)
@@ -139,12 +139,32 @@ export default function LoginPage({
const inviteFlow = searchParams.get('invite_flow') === 'true' const inviteFlow = searchParams.get('invite_flow') === 'true'
setIsInviteFlow(inviteFlow) setIsInviteFlow(inviteFlow)
}
const resetSuccess = searchParams.get('resetSuccess') === 'true' const checkCustomBrand = () => {
if (resetSuccess) { const computedStyle = getComputedStyle(document.documentElement)
setResetSuccessMessage('Password reset successful. Please sign in with your new password.') 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]) }, [searchParams])
useEffect(() => { useEffect(() => {
@@ -182,13 +202,6 @@ export default function LoginPage({
e.preventDefault() e.preventDefault()
setIsLoading(true) setIsLoading(true)
const redirectToVerify = (emailToVerify: string) => {
if (typeof window !== 'undefined') {
sessionStorage.setItem('verificationEmail', emailToVerify)
}
router.push('/verify')
}
const formData = new FormData(e.currentTarget) const formData = new FormData(e.currentTarget)
const emailRaw = formData.get('email') as string const emailRaw = formData.get('email') as string
const email = emailRaw.trim().toLowerCase() const email = emailRaw.trim().toLowerCase()
@@ -208,7 +221,6 @@ export default function LoginPage({
try { try {
const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/workspace' const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/workspace'
let errorHandled = false
const result = await client.signIn.email( const result = await client.signIn.email(
{ {
@@ -219,16 +231,11 @@ export default function LoginPage({
{ {
onError: (ctx) => { onError: (ctx) => {
logger.error('Login error:', ctx.error) logger.error('Login error:', ctx.error)
if (ctx.error.code?.includes('EMAIL_NOT_VERIFIED')) {
errorHandled = true
redirectToVerify(email)
return
}
errorHandled = true
const errorMessage: string[] = ['Invalid email or password'] const errorMessage: string[] = ['Invalid email or password']
if (ctx.error.code?.includes('EMAIL_NOT_VERIFIED')) {
return
}
if ( if (
ctx.error.code?.includes('BAD_REQUEST') || ctx.error.code?.includes('BAD_REQUEST') ||
ctx.error.message?.includes('Email and password sign in is not enabled') ctx.error.message?.includes('Email and password sign in is not enabled')
@@ -264,7 +271,6 @@ export default function LoginPage({
errorMessage.push('Too many requests. Please wait a moment before trying again.') errorMessage.push('Too many requests. Please wait a moment before trying again.')
} }
setResetSuccessMessage(null)
setPasswordErrors(errorMessage) setPasswordErrors(errorMessage)
setShowValidationError(true) setShowValidationError(true)
}, },
@@ -272,25 +278,15 @@ export default function LoginPage({
) )
if (!result || result.error) { 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) setIsLoading(false)
return 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) { } catch (err: any) {
if (err.message?.includes('not verified') || err.code?.includes('EMAIL_NOT_VERIFIED')) { if (err.message?.includes('not verified') || err.code?.includes('EMAIL_NOT_VERIFIED')) {
redirectToVerify(email) if (typeof window !== 'undefined') {
sessionStorage.setItem('verificationEmail', email)
}
router.push('/verify')
return return
} }
@@ -404,13 +400,6 @@ export default function LoginPage({
</div> </div>
)} )}
{/* Password reset success message */}
{resetSuccessMessage && (
<div className={`${inter.className} mt-1 space-y-1 text-[#4CAF50] text-xs`}>
<p>{resetSuccessMessage}</p>
</div>
)}
{/* Email/Password Form - show unless explicitly disabled */} {/* Email/Password Form - show unless explicitly disabled */}
{!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && ( {!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && (
<form onSubmit={onSubmit} className={`${inter.className} mt-8 space-y-8`}> <form onSubmit={onSubmit} className={`${inter.className} mt-8 space-y-8`}>
@@ -493,14 +482,24 @@ export default function LoginPage({
</div> </div>
</div> </div>
<BrandedButton <Button
type='submit' type='submit'
onMouseEnter={() => setIsButtonHovered(true)}
onMouseLeave={() => setIsButtonHovered(false)}
className='group inline-flex w-full 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-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
disabled={isLoading} disabled={isLoading}
loading={isLoading}
loadingText='Signing in'
> >
Sign in <span className='flex items-center gap-1'>
</BrandedButton> {isLoading ? 'Signing in...' : 'Sign in'}
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isButtonHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
</span>
</span>
</Button>
</form> </form>
)} )}
@@ -611,15 +610,25 @@ export default function LoginPage({
<p>{resetStatus.message}</p> <p>{resetStatus.message}</p>
</div> </div>
)} )}
<BrandedButton <Button
type='button' type='button'
onClick={handleForgotPassword} onClick={handleForgotPassword}
onMouseEnter={() => setIsResetButtonHovered(true)}
onMouseLeave={() => setIsResetButtonHovered(false)}
className='group inline-flex w-full 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-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
disabled={isSubmittingReset} disabled={isSubmittingReset}
loading={isSubmittingReset}
loadingText='Sending'
> >
Send Reset Link <span className='flex items-center gap-1'>
</BrandedButton> {isSubmittingReset ? 'Sending...' : 'Send Reset Link'}
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isResetButtonHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
</span>
</span>
</Button>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -1,12 +1,12 @@
'use client' 'use client'
import { useState } from 'react' import { useEffect, useState } from 'react'
import { Eye, EyeOff } from 'lucide-react' import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { inter } from '@/app/_styles/fonts/inter/inter' import { inter } from '@/app/_styles/fonts/inter/inter'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
interface RequestResetFormProps { interface RequestResetFormProps {
email: string email: string
@@ -27,6 +27,36 @@ export function RequestResetForm({
statusMessage, statusMessage,
className, className,
}: RequestResetFormProps) { }: 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
onSubmit(email) onSubmit(email)
@@ -64,14 +94,24 @@ export function RequestResetForm({
)} )}
</div> </div>
<BrandedButton <Button
type='submit' type='submit'
disabled={isSubmitting} disabled={isSubmitting}
loading={isSubmitting} onMouseEnter={() => setIsButtonHovered(true)}
loadingText='Sending' onMouseLeave={() => setIsButtonHovered(false)}
className='group inline-flex w-full 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-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
> >
Send Reset Link <span className='flex items-center gap-1'>
</BrandedButton> {isSubmitting ? 'Sending...' : 'Send Reset Link'}
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isButtonHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
</span>
</span>
</Button>
</form> </form>
) )
} }
@@ -98,6 +138,35 @@ export function SetNewPasswordForm({
const [validationMessage, setValidationMessage] = useState('') const [validationMessage, setValidationMessage] = useState('')
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -227,14 +296,24 @@ export function SetNewPasswordForm({
)} )}
</div> </div>
<BrandedButton <Button
type='submit'
disabled={isSubmitting || !token} disabled={isSubmitting || !token}
loading={isSubmitting} type='submit'
loadingText='Resetting' onMouseEnter={() => setIsButtonHovered(true)}
onMouseLeave={() => setIsButtonHovered(false)}
className='group inline-flex w-full 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-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
> >
Reset Password <span className='flex items-center gap-1'>
</BrandedButton> {isSubmitting ? 'Resetting...' : 'Reset Password'}
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isButtonHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
</span>
</span>
</Button>
</form> </form>
) )
} }

View File

@@ -2,9 +2,10 @@
import { Suspense, useEffect, useState } from 'react' import { Suspense, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { Eye, EyeOff } from 'lucide-react' import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { client, useSession } from '@/lib/auth/auth-client' import { client, useSession } from '@/lib/auth/auth-client'
@@ -13,10 +14,8 @@ import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation' import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { inter } from '@/app/_styles/fonts/inter/inter' import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne' 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 { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button' import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
const logger = createLogger('SignupForm') const logger = createLogger('SignupForm')
@@ -96,7 +95,8 @@ function SignupFormContent({
const [showEmailValidationError, setShowEmailValidationError] = useState(false) const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [redirectUrl, setRedirectUrl] = useState('') const [redirectUrl, setRedirectUrl] = useState('')
const [isInviteFlow, setIsInviteFlow] = useState(false) const [isInviteFlow, setIsInviteFlow] = useState(false)
const buttonClass = useBrandedButtonClass() const [buttonClass, setButtonClass] = useState('branded-button-gradient')
const [isButtonHovered, setIsButtonHovered] = useState(false)
const [name, setName] = useState('') const [name, setName] = useState('')
const [nameErrors, setNameErrors] = useState<string[]>([]) const [nameErrors, setNameErrors] = useState<string[]>([])
@@ -126,6 +126,31 @@ function SignupFormContent({
if (inviteFlowParam === 'true') { if (inviteFlowParam === 'true') {
setIsInviteFlow(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]) }, [searchParams])
const validatePassword = (passwordValue: string): string[] => { const validatePassword = (passwordValue: string): string[] => {
@@ -475,14 +500,24 @@ function SignupFormContent({
</div> </div>
</div> </div>
<BrandedButton <Button
type='submit' type='submit'
onMouseEnter={() => setIsButtonHovered(true)}
onMouseLeave={() => setIsButtonHovered(false)}
className='group inline-flex w-full 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-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
disabled={isLoading} disabled={isLoading}
loading={isLoading}
loadingText='Creating account'
> >
Create account <span className='flex items-center gap-1'>
</BrandedButton> {isLoading ? 'Creating account' : 'Create account'}
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isButtonHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
</span>
</span>
</Button>
</form> </form>
)} )}

View File

@@ -13,7 +13,6 @@ import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation' import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { inter } from '@/app/_styles/fonts/inter/inter' import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne' import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
const logger = createLogger('SSOForm') const logger = createLogger('SSOForm')
@@ -58,7 +57,7 @@ export default function SSOForm() {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [emailErrors, setEmailErrors] = useState<string[]>([]) const [emailErrors, setEmailErrors] = useState<string[]>([])
const [showEmailValidationError, setShowEmailValidationError] = useState(false) const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const buttonClass = useBrandedButtonClass() const [buttonClass, setButtonClass] = useState('branded-button-gradient')
const [callbackUrl, setCallbackUrl] = useState('/workspace') const [callbackUrl, setCallbackUrl] = useState('/workspace')
useEffect(() => { useEffect(() => {
@@ -91,6 +90,31 @@ export default function SSOForm() {
setShowEmailValidationError(true) 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]) }, [searchParams])
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {

View File

@@ -8,7 +8,6 @@ import { cn } from '@/lib/core/utils/cn'
import { inter } from '@/app/_styles/fonts/inter/inter' import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne' import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { useVerification } from '@/app/(auth)/verify/use-verification' import { useVerification } from '@/app/(auth)/verify/use-verification'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
interface VerifyContentProps { interface VerifyContentProps {
hasEmailService: boolean hasEmailService: boolean
@@ -59,7 +58,34 @@ function VerificationForm({
setCountdown(30) setCountdown(30)
} }
const buttonClass = useBrandedButtonClass() 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()
}
}, [])
return ( return (
<> <>

View File

@@ -4,6 +4,7 @@ import { useRef, useState } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { Textarea } from '@/components/emcn' import { Textarea } from '@/components/emcn'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { import {
@@ -17,7 +18,6 @@ import { isHosted } from '@/lib/core/config/feature-flags'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation' import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { soehne } from '@/app/_styles/fonts/soehne/soehne' 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 Footer from '@/app/(landing)/components/footer/footer'
import Nav from '@/app/(landing)/components/nav/nav' import Nav from '@/app/(landing)/components/nav/nav'
@@ -493,17 +493,18 @@ export default function CareersPage() {
{/* Submit Button */} {/* Submit Button */}
<div className='flex justify-end pt-2'> <div className='flex justify-end pt-2'>
<BrandedButton <Button
type='submit' type='submit'
disabled={isSubmitting || submitStatus === 'success'} disabled={isSubmitting || submitStatus === 'success'}
loading={isSubmitting} className='min-w-[200px] rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all duration-300 hover:opacity-90 disabled:opacity-50'
loadingText='Submitting' size='lg'
showArrow={false}
fullWidth={false}
className='min-w-[200px]'
> >
{submitStatus === 'success' ? 'Submitted' : 'Submit Application'} {isSubmitting
</BrandedButton> ? 'Submitting...'
: submitStatus === 'success'
? 'Submitted'
: 'Submit Application'}
</Button>
</div> </div>
</form> </form>
</section> </section>

View File

@@ -11,7 +11,6 @@ import { useBrandConfig } from '@/lib/branding/branding'
import { isHosted } from '@/lib/core/config/feature-flags' import { isHosted } from '@/lib/core/config/feature-flags'
import { soehne } from '@/app/_styles/fonts/soehne/soehne' import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github' import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
const logger = createLogger('nav') const logger = createLogger('nav')
@@ -21,12 +20,11 @@ interface NavProps {
} }
export default function Nav({ hideAuthButtons = false, variant = 'landing' }: NavProps = {}) { export default function Nav({ hideAuthButtons = false, variant = 'landing' }: NavProps = {}) {
const [githubStars, setGithubStars] = useState('25.8k') const [githubStars, setGithubStars] = useState('25.1k')
const [isHovered, setIsHovered] = useState(false) const [isHovered, setIsHovered] = useState(false)
const [isLoginHovered, setIsLoginHovered] = useState(false) const [isLoginHovered, setIsLoginHovered] = useState(false)
const router = useRouter() const router = useRouter()
const brand = useBrandConfig() const brand = useBrandConfig()
const buttonClass = useBrandedButtonClass()
useEffect(() => { useEffect(() => {
if (variant !== 'landing') return if (variant !== 'landing') return
@@ -185,7 +183,7 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
href='/signup' href='/signup'
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
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`} 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]'
aria-label='Get started with Sim - Sign up for free' aria-label='Get started with Sim - Sign up for free'
prefetch={true} prefetch={true}
> >

View File

@@ -4,11 +4,6 @@ import { createLogger } from '@sim/logger'
import { and, desc, eq, inArray } from 'drizzle-orm' import { and, desc, eq, inArray } from 'drizzle-orm'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { refreshOAuthToken } from '@/lib/oauth' import { refreshOAuthToken } from '@/lib/oauth'
import {
getMicrosoftRefreshTokenExpiry,
isMicrosoftProvider,
PROACTIVE_REFRESH_THRESHOLD_DAYS,
} from '@/lib/oauth/microsoft'
const logger = createLogger('OAuthUtilsAPI') const logger = createLogger('OAuthUtilsAPI')
@@ -210,32 +205,15 @@ export async function refreshAccessTokenIfNeeded(
} }
// Decide if we should refresh: token missing OR expired // Decide if we should refresh: token missing OR expired
const accessTokenExpiresAt = credential.accessTokenExpiresAt const expiresAt = credential.accessTokenExpiresAt
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
const now = new Date() const now = new Date()
const shouldRefresh =
// Check if access token needs refresh (missing or expired) !!credential.refreshToken && (!credential.accessToken || (expiresAt && expiresAt <= now))
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 const accessToken = credential.accessToken
if (shouldRefresh) { if (shouldRefresh) {
logger.info(`[${requestId}] Refreshing token for credential`) logger.info(`[${requestId}] Token expired, attempting to refresh for credential`)
try { try {
const refreshedToken = await refreshOAuthToken( const refreshedToken = await refreshOAuthToken(
credential.providerId, credential.providerId,
@@ -249,15 +227,11 @@ export async function refreshAccessTokenIfNeeded(
userId: credential.userId, userId: credential.userId,
hasRefreshToken: !!credential.refreshToken, hasRefreshToken: !!credential.refreshToken,
}) })
if (!accessTokenNeedsRefresh && accessToken) {
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
return accessToken
}
return null return null
} }
// Prepare update data // Prepare update data
const updateData: Record<string, unknown> = { const updateData: any = {
accessToken: refreshedToken.accessToken, accessToken: refreshedToken.accessToken,
accessTokenExpiresAt: new Date(Date.now() + refreshedToken.expiresIn * 1000), accessTokenExpiresAt: new Date(Date.now() + refreshedToken.expiresIn * 1000),
updatedAt: new Date(), updatedAt: new Date(),
@@ -269,10 +243,6 @@ export async function refreshAccessTokenIfNeeded(
updateData.refreshToken = refreshedToken.refreshToken updateData.refreshToken = refreshedToken.refreshToken
} }
if (isMicrosoftProvider(credential.providerId)) {
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
}
// Update the token in the database // Update the token in the database
await db.update(account).set(updateData).where(eq(account.id, credentialId)) await db.update(account).set(updateData).where(eq(account.id, credentialId))
@@ -286,10 +256,6 @@ export async function refreshAccessTokenIfNeeded(
credentialId, credentialId,
userId: credential.userId, userId: credential.userId,
}) })
if (!accessTokenNeedsRefresh && accessToken) {
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
return accessToken
}
return null return null
} }
} else if (!accessToken) { } else if (!accessToken) {
@@ -311,27 +277,10 @@ export async function refreshTokenIfNeeded(
credentialId: string credentialId: string
): Promise<{ accessToken: string; refreshed: boolean }> { ): Promise<{ accessToken: string; refreshed: boolean }> {
// Decide if we should refresh: token missing OR expired // Decide if we should refresh: token missing OR expired
const accessTokenExpiresAt = credential.accessTokenExpiresAt const expiresAt = credential.accessTokenExpiresAt
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
const now = new Date() const now = new Date()
const shouldRefresh =
// Check if access token needs refresh (missing or expired) !!credential.refreshToken && (!credential.accessToken || (expiresAt && expiresAt <= now))
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 token appears valid and present, return it directly
if (!shouldRefresh) { if (!shouldRefresh) {
@@ -344,17 +293,13 @@ export async function refreshTokenIfNeeded(
if (!refreshResult) { if (!refreshResult) {
logger.error(`[${requestId}] Failed to refresh token for credential`) 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') throw new Error('Failed to refresh token')
} }
const { accessToken: refreshedToken, expiresIn, refreshToken: newRefreshToken } = refreshResult const { accessToken: refreshedToken, expiresIn, refreshToken: newRefreshToken } = refreshResult
// Prepare update data // Prepare update data
const updateData: Record<string, unknown> = { const updateData: any = {
accessToken: refreshedToken, accessToken: refreshedToken,
accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000), // Use provider's expiry accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000), // Use provider's expiry
updatedAt: new Date(), updatedAt: new Date(),
@@ -366,10 +311,6 @@ export async function refreshTokenIfNeeded(
updateData.refreshToken = newRefreshToken updateData.refreshToken = newRefreshToken
} }
if (isMicrosoftProvider(credential.providerId)) {
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
}
await db.update(account).set(updateData).where(eq(account.id, credentialId)) await db.update(account).set(updateData).where(eq(account.id, credentialId))
logger.info(`[${requestId}] Successfully refreshed access token`) logger.info(`[${requestId}] Successfully refreshed access token`)
@@ -390,11 +331,6 @@ 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) logger.error(`[${requestId}] Refresh failed and no valid token found in DB`, error)
throw error throw error
} }

View File

@@ -15,8 +15,7 @@ const resetPasswordSchema = z.object({
.max(100, 'Password must not exceed 100 characters') .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 uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase 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) { export async function POST(request: NextRequest) {

View File

@@ -276,11 +276,8 @@ describe('Function Execute API Route', () => {
it.concurrent('should resolve tag variables with <tag_name> syntax', async () => { it.concurrent('should resolve tag variables with <tag_name> syntax', async () => {
const req = createMockRequest('POST', { const req = createMockRequest('POST', {
code: 'return <email>', code: 'return <email>',
blockData: { params: {
'block-123': { id: '123', subject: 'Test Email' }, email: { id: '123', subject: 'Test Email' },
},
blockNameMapping: {
email: 'block-123',
}, },
}) })
@@ -308,13 +305,9 @@ describe('Function Execute API Route', () => {
it.concurrent('should only match valid variable names in angle brackets', async () => { it.concurrent('should only match valid variable names in angle brackets', async () => {
const req = createMockRequest('POST', { const req = createMockRequest('POST', {
code: 'return <validVar> + "<invalid@email.com>" + <another_valid>', code: 'return <validVar> + "<invalid@email.com>" + <another_valid>',
blockData: { params: {
'block-1': 'hello', validVar: 'hello',
'block-2': 'world', another_valid: 'world',
},
blockNameMapping: {
validVar: 'block-1',
another_valid: 'block-2',
}, },
}) })
@@ -328,22 +321,28 @@ describe('Function Execute API Route', () => {
it.concurrent( it.concurrent(
'should handle Gmail webhook data with email addresses containing angle brackets', 'should handle Gmail webhook data with email addresses containing angle brackets',
async () => { async () => {
const emailData = { const gmailData = {
id: '123', email: {
from: 'Waleed Latif <waleed@sim.ai>', id: '123',
to: 'User <user@example.com>', from: 'Waleed Latif <waleed@sim.ai>',
subject: 'Test Email', to: 'User <user@example.com>',
bodyText: 'Hello world', subject: 'Test Email',
bodyText: 'Hello world',
},
rawEmail: {
id: '123',
payload: {
headers: [
{ name: 'From', value: 'Waleed Latif <waleed@sim.ai>' },
{ name: 'To', value: 'User <user@example.com>' },
],
},
},
} }
const req = createMockRequest('POST', { const req = createMockRequest('POST', {
code: 'return <email>', code: 'return <email>',
blockData: { params: gmailData,
'block-email': emailData,
},
blockNameMapping: {
email: 'block-email',
},
}) })
const response = await POST(req) const response = await POST(req)
@@ -357,20 +356,17 @@ describe('Function Execute API Route', () => {
it.concurrent( it.concurrent(
'should properly serialize complex email objects with special characters', 'should properly serialize complex email objects with special characters',
async () => { async () => {
const emailData = { const complexEmailData = {
from: 'Test User <test@example.com>', email: {
bodyHtml: '<div>HTML content with "quotes" and \'apostrophes\'</div>', from: 'Test User <test@example.com>',
bodyText: 'Text with\nnewlines\tand\ttabs', bodyHtml: '<div>HTML content with "quotes" and \'apostrophes\'</div>',
bodyText: 'Text with\nnewlines\tand\ttabs',
},
} }
const req = createMockRequest('POST', { const req = createMockRequest('POST', {
code: 'return <email>', code: 'return <email>',
blockData: { params: complexEmailData,
'block-email': emailData,
},
blockNameMapping: {
email: 'block-email',
},
}) })
const response = await POST(req) const response = await POST(req)
@@ -523,23 +519,18 @@ describe('Function Execute API Route', () => {
}) })
it.concurrent('should handle JSON serialization edge cases', async () => { 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', { const req = createMockRequest('POST', {
code: 'return <complexData>', code: 'return <complexData>',
blockData: { params: {
'block-complex': complexData, complexData: {
}, special: 'chars"with\'quotes',
blockNameMapping: { unicode: '🎉 Unicode content',
complexData: 'block-complex', nested: {
deep: {
value: 'test',
},
},
},
}, },
}) })

View File

@@ -9,8 +9,8 @@ import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants'
import { import {
createEnvVarPattern, createEnvVarPattern,
createWorkflowVariablePattern, createWorkflowVariablePattern,
resolveEnvVarReferences,
} from '@/executor/utils/reference-validation' } from '@/executor/utils/reference-validation'
import { navigatePath } from '@/executor/variables/resolvers/reference'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export const runtime = 'nodejs' export const runtime = 'nodejs'
@@ -18,8 +18,8 @@ export const MAX_DURATION = 210
const logger = createLogger('FunctionExecuteAPI') const logger = createLogger('FunctionExecuteAPI')
const E2B_JS_WRAPPER_LINES = 3 const E2B_JS_WRAPPER_LINES = 3 // Lines before user code: ';(async () => {', ' try {', ' const __sim_result = await (async () => {'
const E2B_PYTHON_WRAPPER_LINES = 1 const E2B_PYTHON_WRAPPER_LINES = 1 // Lines before user code: 'def __sim_main__():'
type TypeScriptModule = typeof import('typescript') type TypeScriptModule = typeof import('typescript')
@@ -134,21 +134,33 @@ function extractEnhancedError(
if (error.stack) { if (error.stack) {
enhanced.stack = 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') const stackLines: string[] = error.stack.split('\n')
for (const line of stackLines) { for (const line of stackLines) {
// Pattern 1: Compilation errors - "user-function.js:6"
let match = line.match(/user-function\.js:(\d+)(?::(\d+))?/) let match = line.match(/user-function\.js:(\d+)(?::(\d+))?/)
// Pattern 2: Runtime errors - "at user-function.js:5:12"
if (!match) { if (!match) {
match = line.match(/at\s+user-function\.js:(\d+):(\d+)/) 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) { if (match) {
const stackLine = Number.parseInt(match[1], 10) const stackLine = Number.parseInt(match[1], 10)
const stackColumn = match[2] ? Number.parseInt(match[2], 10) : undefined 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 const adjustedLine = stackLine - userCodeStartLine + 1
// Check if this is a syntax error in wrapper code caused by incomplete user code
const isWrapperSyntaxError = const isWrapperSyntaxError =
stackLine > userCodeStartLine && stackLine > userCodeStartLine &&
error.name === 'SyntaxError' && error.name === 'SyntaxError' &&
@@ -156,6 +168,7 @@ function extractEnhancedError(
error.message.includes('Unexpected end of input')) error.message.includes('Unexpected end of input'))
if (isWrapperSyntaxError && userCode) { if (isWrapperSyntaxError && userCode) {
// Map wrapper syntax errors to the last line of user code
const codeLines = userCode.split('\n') const codeLines = userCode.split('\n')
const lastUserLine = codeLines.length const lastUserLine = codeLines.length
enhanced.line = lastUserLine enhanced.line = lastUserLine
@@ -168,6 +181,7 @@ function extractEnhancedError(
enhanced.line = adjustedLine enhanced.line = adjustedLine
enhanced.column = stackColumn enhanced.column = stackColumn
// Extract the actual line content from user code
if (userCode) { if (userCode) {
const codeLines = userCode.split('\n') const codeLines = userCode.split('\n')
if (adjustedLine <= codeLines.length) { if (adjustedLine <= codeLines.length) {
@@ -178,6 +192,7 @@ function extractEnhancedError(
} }
if (stackLine <= userCodeStartLine) { if (stackLine <= userCodeStartLine) {
// Error is in wrapper code itself
enhanced.line = stackLine enhanced.line = stackLine
enhanced.column = stackColumn enhanced.column = stackColumn
break break
@@ -185,6 +200,7 @@ function extractEnhancedError(
} }
} }
// Clean up stack trace to show user-relevant information
const cleanedStackLines: string[] = stackLines const cleanedStackLines: string[] = stackLines
.filter( .filter(
(line: string) => (line: string) =>
@@ -198,6 +214,9 @@ function extractEnhancedError(
} }
} }
// Keep original message without adding error type prefix
// The error type will be added later in createUserFriendlyErrorMessage
return enhanced return enhanced
} }
@@ -212,6 +231,7 @@ function formatE2BError(
userCode: string, userCode: string,
prologueLineCount: number prologueLineCount: number
): { formattedError: string; cleanedOutput: string } { ): { formattedError: string; cleanedOutput: string } {
// Calculate line offset based on language and prologue
const wrapperLines = const wrapperLines =
language === CodeLanguage.Python ? E2B_PYTHON_WRAPPER_LINES : E2B_JS_WRAPPER_LINES language === CodeLanguage.Python ? E2B_PYTHON_WRAPPER_LINES : E2B_JS_WRAPPER_LINES
const totalOffset = prologueLineCount + wrapperLines const totalOffset = prologueLineCount + wrapperLines
@@ -221,20 +241,27 @@ function formatE2BError(
let cleanErrorMsg = '' let cleanErrorMsg = ''
if (language === CodeLanguage.Python) { 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+)/) const cellMatch = errorOutput.match(/Cell In\[\d+\], line (\d+)/)
if (cellMatch) { if (cellMatch) {
const originalLine = Number.parseInt(cellMatch[1], 10) const originalLine = Number.parseInt(cellMatch[1], 10)
userLine = originalLine - totalOffset 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 cleanErrorMsg = errorMessage
.replace(/\s*\(detected at line \d+\)/g, '') .replace(/\s*\(detected at line \d+\)/g, '')
.replace(/\s*\([^)]+\.py, line \d+\)/g, '') .replace(/\s*\([^)]+\.py, line \d+\)/g, '')
.trim() .trim()
} else if (language === CodeLanguage.JavaScript) { } 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 firstLineEnd = errorMessage.indexOf('\n')
const firstLine = firstLineEnd > 0 ? errorMessage.substring(0, firstLineEnd) : errorMessage 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+)\)/) const jsErrorMatch = firstLine.match(/^(\w+Error):\s*[^:]+:\s*([^(]+)\.\s*\((\d+):(\d+)\)/)
if (jsErrorMatch) { if (jsErrorMatch) {
cleanErrorType = jsErrorMatch[1] cleanErrorType = jsErrorMatch[1]
@@ -242,11 +269,13 @@ function formatE2BError(
const originalLine = Number.parseInt(jsErrorMatch[3], 10) const originalLine = Number.parseInt(jsErrorMatch[3], 10)
userLine = originalLine - totalOffset userLine = originalLine - totalOffset
} else { } else {
// Fallback: look for line number in the arrow pointer line (> 11 |)
const arrowMatch = errorMessage.match(/^>\s*(\d+)\s*\|/m) const arrowMatch = errorMessage.match(/^>\s*(\d+)\s*\|/m)
if (arrowMatch) { if (arrowMatch) {
const originalLine = Number.parseInt(arrowMatch[1], 10) const originalLine = Number.parseInt(arrowMatch[1], 10)
userLine = originalLine - totalOffset userLine = originalLine - totalOffset
} }
// Try to extract error type and message
const errorMatch = firstLine.match(/^(\w+Error):\s*(.+)/) const errorMatch = firstLine.match(/^(\w+Error):\s*(.+)/)
if (errorMatch) { if (errorMatch) {
cleanErrorType = errorMatch[1] cleanErrorType = errorMatch[1]
@@ -260,11 +289,13 @@ function formatE2BError(
} }
} }
// Build the final clean error message
const finalErrorMsg = const finalErrorMsg =
cleanErrorType && cleanErrorMsg cleanErrorType && cleanErrorMsg
? `${cleanErrorType}: ${cleanErrorMsg}` ? `${cleanErrorType}: ${cleanErrorMsg}`
: cleanErrorMsg || errorMessage : cleanErrorMsg || errorMessage
// Format with line number if available
let formattedError = finalErrorMsg let formattedError = finalErrorMsg
if (userLine && userLine > 0) { if (userLine && userLine > 0) {
const codeLines = userCode.split('\n') const codeLines = userCode.split('\n')
@@ -280,6 +311,7 @@ function formatE2BError(
} }
} }
// For stdout, just return the clean error message without the full traceback
const cleanedOutput = finalErrorMsg const cleanedOutput = finalErrorMsg
return { formattedError, cleanedOutput } return { formattedError, cleanedOutput }
@@ -295,6 +327,7 @@ function createUserFriendlyErrorMessage(
): string { ): string {
let errorMessage = enhanced.message let errorMessage = enhanced.message
// Add line information if available
if (enhanced.line !== undefined) { if (enhanced.line !== undefined) {
let lineInfo = `Line ${enhanced.line}` let lineInfo = `Line ${enhanced.line}`
@@ -305,14 +338,18 @@ function createUserFriendlyErrorMessage(
errorMessage = `${lineInfo} - ${errorMessage}` errorMessage = `${lineInfo} - ${errorMessage}`
} else { } else {
// If no line number, try to extract it from stack trace for display
if (enhanced.stack) { if (enhanced.stack) {
const stackMatch = enhanced.stack.match(/user-function\.js:(\d+)(?::(\d+))?/) const stackMatch = enhanced.stack.match(/user-function\.js:(\d+)(?::(\d+))?/)
if (stackMatch) { if (stackMatch) {
const line = Number.parseInt(stackMatch[1], 10) const line = Number.parseInt(stackMatch[1], 10)
let lineInfo = `Line ${line}` let lineInfo = `Line ${line}`
// Try to get line content if we have userCode
if (userCode) { if (userCode) {
const codeLines = userCode.split('\n') 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) { if (line <= codeLines.length) {
const lineContent = codeLines[line - 1]?.trim() const lineContent = codeLines[line - 1]?.trim()
if (lineContent) { if (lineContent) {
@@ -326,6 +363,7 @@ function createUserFriendlyErrorMessage(
} }
} }
// Add error type prefix with consistent naming
if (enhanced.name !== 'Error') { if (enhanced.name !== 'Error') {
const errorTypePrefix = const errorTypePrefix =
enhanced.name === 'SyntaxError' enhanced.name === 'SyntaxError'
@@ -336,6 +374,7 @@ function createUserFriendlyErrorMessage(
? 'Reference Error' ? 'Reference Error'
: enhanced.name : enhanced.name
// Only add prefix if not already present
if (!errorMessage.toLowerCase().includes(errorTypePrefix.toLowerCase())) { if (!errorMessage.toLowerCase().includes(errorTypePrefix.toLowerCase())) {
errorMessage = `${errorTypePrefix}: ${errorMessage}` errorMessage = `${errorTypePrefix}: ${errorMessage}`
} }
@@ -344,6 +383,9 @@ function createUserFriendlyErrorMessage(
return errorMessage return errorMessage
} }
/**
* Resolves workflow variables with <variable.name> syntax
*/
function resolveWorkflowVariables( function resolveWorkflowVariables(
code: string, code: string,
workflowVariables: Record<string, any>, workflowVariables: Record<string, any>,
@@ -363,35 +405,39 @@ function resolveWorkflowVariables(
while ((match = regex.exec(code)) !== null) { while ((match = regex.exec(code)) !== null) {
const variableName = match[1].trim() 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( const foundVariable = Object.entries(workflowVariables).find(
([_, variable]) => normalizeName(variable.name || '') === variableName ([_, variable]) => normalizeName(variable.name || '') === variableName
) )
if (!foundVariable) { let variableValue: unknown = ''
const availableVars = Object.values(workflowVariables) if (foundVariable) {
.map((v) => v.name) const variable = foundVariable[1]
.filter(Boolean) variableValue = variable.value
throw new Error(
`Variable "${variableName}" doesn't exist.` +
(availableVars.length > 0 ? ` Available: ${availableVars.join(', ')}` : '')
)
}
const variable = foundVariable[1] if (variable.value !== undefined && variable.value !== null) {
let variableValue: unknown = variable.value
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 { try {
variableValue = JSON.parse(variableValue) // 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
}
}
} catch { } catch {
// Keep as-is // Fallback to original value on error
variableValue = variable.value
} }
} }
} }
@@ -404,9 +450,11 @@ function resolveWorkflowVariables(
}) })
} }
// Process replacements in reverse order to maintain correct indices
for (let i = replacements.length - 1; i >= 0; i--) { for (let i = replacements.length - 1; i >= 0; i--) {
const { match: matchStr, index, variableName, variableValue } = replacements[i] const { match: matchStr, index, variableName, variableValue } = replacements[i]
// Use variable reference approach
const safeVarName = `__variable_${variableName.replace(/[^a-zA-Z0-9_]/g, '_')}` const safeVarName = `__variable_${variableName.replace(/[^a-zA-Z0-9_]/g, '_')}`
contextVariables[safeVarName] = variableValue contextVariables[safeVarName] = variableValue
resolvedCode = resolvedCode =
@@ -416,6 +464,9 @@ function resolveWorkflowVariables(
return resolvedCode return resolvedCode
} }
/**
* Resolves environment variables with {{var_name}} syntax
*/
function resolveEnvironmentVariables( function resolveEnvironmentVariables(
code: string, code: string,
params: Record<string, any>, params: Record<string, any>,
@@ -431,28 +482,32 @@ function resolveEnvironmentVariables(
const resolverVars: Record<string, string> = {} const resolverVars: Record<string, string> = {}
Object.entries(params).forEach(([key, value]) => { Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) { if (value) {
resolverVars[key] = String(value) resolverVars[key] = String(value)
} }
}) })
Object.entries(envVars).forEach(([key, value]) => { Object.entries(envVars).forEach(([key, value]) => {
if (value !== undefined && value !== null) { if (value) {
resolverVars[key] = value resolverVars[key] = value
} }
}) })
while ((match = regex.exec(code)) !== null) { while ((match = regex.exec(code)) !== null) {
const varName = match[1].trim() const varName = match[1].trim()
const resolved = resolveEnvVarReferences(match[0], resolverVars, {
if (!(varName in resolverVars)) { allowEmbedded: true,
continue resolveExactMatch: true,
} trimKeys: true,
onMissing: 'empty',
deep: false,
})
const varValue =
typeof resolved === 'string' ? resolved : resolved == null ? '' : String(resolved)
replacements.push({ replacements.push({
match: match[0], match: match[0],
index: match.index, index: match.index,
varName, varName,
varValue: resolverVars[varName], varValue: String(varValue),
}) })
} }
@@ -468,8 +523,12 @@ function resolveEnvironmentVariables(
return resolvedCode return resolvedCode
} }
/**
* Resolves tags with <tag_name> syntax (including nested paths like <block.response.data>)
*/
function resolveTagVariables( function resolveTagVariables(
code: string, code: string,
params: Record<string, any>,
blockData: Record<string, any>, blockData: Record<string, any>,
blockNameMapping: Record<string, string>, blockNameMapping: Record<string, string>,
contextVariables: Record<string, any> contextVariables: Record<string, any>
@@ -484,30 +543,27 @@ function resolveTagVariables(
for (const match of tagMatches) { for (const match of tagMatches) {
const tagName = match.slice(REFERENCE.START.length, -REFERENCE.END.length).trim() const tagName = match.slice(REFERENCE.START.length, -REFERENCE.END.length).trim()
const pathParts = tagName.split(REFERENCE.PATH_DELIMITER)
const blockName = pathParts[0]
const blockId = blockNameMapping[blockName] // Handle nested paths like "getrecord.response.data" or "function1.response.result"
if (!blockId) { // First try params, then blockData directly, then try with block name mapping
continue let tagValue = getNestedValue(params, tagName) || getNestedValue(blockData, tagName) || ''
}
// If not found and the path starts with a block name, try mapping the block name to ID
const blockOutput = blockData[blockId] if (!tagValue && tagName.includes(REFERENCE.PATH_DELIMITER)) {
if (blockOutput === undefined) { const pathParts = tagName.split(REFERENCE.PATH_DELIMITER)
continue const normalizedBlockName = pathParts[0] // This should already be normalized like "function1"
}
// Direct lookup using normalized block name
let tagValue: any const blockId = blockNameMapping[normalizedBlockName] ?? null
if (pathParts.length === 1) {
tagValue = blockOutput if (blockId) {
} else { const remainingPath = pathParts.slice(1).join('.')
tagValue = navigatePath(blockOutput, pathParts.slice(1)) 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 ( if (
typeof tagValue === 'string' && typeof tagValue === 'string' &&
tagValue.length > 100 && tagValue.length > 100 &&
@@ -515,13 +571,16 @@ function resolveTagVariables(
) { ) {
try { try {
tagValue = JSON.parse(tagValue) tagValue = JSON.parse(tagValue)
} catch { } catch (e) {
// Keep as-is // Keep as string if parsing fails
} }
} }
// Instead of injecting large JSON directly, create a variable reference
const safeVarName = `__tag_${tagName.replace(/[^a-zA-Z0-9_]/g, '_')}` const safeVarName = `__tag_${tagName.replace(/[^a-zA-Z0-9_]/g, '_')}`
contextVariables[safeVarName] = tagValue contextVariables[safeVarName] = tagValue
// Replace the template with a variable reference
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), safeVarName) resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), safeVarName)
} }
@@ -546,13 +605,35 @@ function resolveCodeVariables(
let resolvedCode = code let resolvedCode = code
const contextVariables: Record<string, any> = {} const contextVariables: Record<string, any> = {}
// Resolve workflow variables with <variable.name> syntax first
resolvedCode = resolveWorkflowVariables(resolvedCode, workflowVariables, contextVariables) resolvedCode = resolveWorkflowVariables(resolvedCode, workflowVariables, contextVariables)
// Resolve environment variables with {{var_name}} syntax
resolvedCode = resolveEnvironmentVariables(resolvedCode, params, envVars, contextVariables) resolvedCode = resolveEnvironmentVariables(resolvedCode, params, envVars, contextVariables)
resolvedCode = resolveTagVariables(resolvedCode, blockData, blockNameMapping, contextVariables)
// Resolve tags with <tag_name> syntax (including nested paths like <block.response.data>)
resolvedCode = resolveTagVariables(
resolvedCode,
params,
blockData,
blockNameMapping,
contextVariables
)
return { resolvedCode, 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 * Remove one trailing newline from stdout
* This handles the common case where print() or console.log() adds a trailing \n * This handles the common case where print() or console.log() adds a trailing \n
@@ -590,6 +671,7 @@ export async function POST(req: NextRequest) {
isCustomTool = false, isCustomTool = false,
} = body } = body
// Extract internal parameters that shouldn't be passed to the execution context
const executionParams = { ...params } const executionParams = { ...params }
executionParams._context = undefined executionParams._context = undefined
@@ -615,6 +697,7 @@ export async function POST(req: NextRequest) {
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
// Extract imports once for JavaScript code (reuse later to avoid double extraction)
let jsImports = '' let jsImports = ''
let jsRemainingCode = resolvedCode let jsRemainingCode = resolvedCode
let hasImports = false let hasImports = false
@@ -624,22 +707,31 @@ export async function POST(req: NextRequest) {
jsImports = extractionResult.imports jsImports = extractionResult.imports
jsRemainingCode = extractionResult.remainingCode 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) const hasRequireStatements = /require\s*\(\s*['"`]/.test(resolvedCode)
hasImports = jsImports.trim().length > 0 || hasRequireStatements hasImports = jsImports.trim().length > 0 || hasRequireStatements
} }
// Python always requires E2B
if (lang === CodeLanguage.Python && !isE2bEnabled) { if (lang === CodeLanguage.Python && !isE2bEnabled) {
throw new Error( throw new Error(
'Python execution requires E2B to be enabled. Please contact your administrator to enable E2B, or use JavaScript instead.' '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) { if (lang === CodeLanguage.JavaScript && hasImports && !isE2bEnabled) {
throw new Error( 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.' '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 = const useE2B =
isE2bEnabled && isE2bEnabled &&
!isCustomTool && !isCustomTool &&
@@ -652,10 +744,13 @@ export async function POST(req: NextRequest) {
language: lang, language: lang,
}) })
let prologue = '' let prologue = ''
const epilogue = ''
if (lang === CodeLanguage.JavaScript) { if (lang === CodeLanguage.JavaScript) {
// Track prologue lines for error adjustment
let prologueLineCount = 0 let prologueLineCount = 0
// Reuse the imports we already extracted earlier
const imports = jsImports const imports = jsImports
const remainingCode = jsRemainingCode const remainingCode = jsRemainingCode
@@ -687,7 +782,7 @@ export async function POST(req: NextRequest) {
' }', ' }',
'})();', '})();',
].join('\n') ].join('\n')
const codeForE2B = importSection + prologue + wrapped const codeForE2B = importSection + prologue + wrapped + epilogue
const execStart = Date.now() const execStart = Date.now()
const { const {
@@ -709,6 +804,7 @@ export async function POST(req: NextRequest) {
error: e2bError, error: e2bError,
}) })
// If there was an execution error, format it properly
if (e2bError) { if (e2bError) {
const { formattedError, cleanedOutput } = formatE2BError( const { formattedError, cleanedOutput } = formatE2BError(
e2bError, e2bError,
@@ -732,7 +828,7 @@ export async function POST(req: NextRequest) {
output: { result: e2bResult ?? null, stdout: cleanStdout(stdout), executionTime }, output: { result: e2bResult ?? null, stdout: cleanStdout(stdout), executionTime },
}) })
} }
// Track prologue lines for error adjustment
let prologueLineCount = 0 let prologueLineCount = 0
prologue += 'import json\n' prologue += 'import json\n'
prologueLineCount++ prologueLineCount++
@@ -750,7 +846,7 @@ export async function POST(req: NextRequest) {
'__sim_result__ = __sim_main__()', '__sim_result__ = __sim_main__()',
"print('__SIM_RESULT__=' + json.dumps(__sim_result__))", "print('__SIM_RESULT__=' + json.dumps(__sim_result__))",
].join('\n') ].join('\n')
const codeForE2B = prologue + wrapped const codeForE2B = prologue + wrapped + epilogue
const execStart = Date.now() const execStart = Date.now()
const { const {
@@ -772,6 +868,7 @@ export async function POST(req: NextRequest) {
error: e2bError, error: e2bError,
}) })
// If there was an execution error, format it properly
if (e2bError) { if (e2bError) {
const { formattedError, cleanedOutput } = formatE2BError( const { formattedError, cleanedOutput } = formatE2BError(
e2bError, e2bError,
@@ -800,6 +897,7 @@ export async function POST(req: NextRequest) {
const wrapperLines = ['(async () => {', ' try {'] const wrapperLines = ['(async () => {', ' try {']
if (isCustomTool) { if (isCustomTool) {
wrapperLines.push(' // For custom tools, make parameters directly accessible')
Object.keys(executionParams).forEach((key) => { Object.keys(executionParams).forEach((key) => {
wrapperLines.push(` const ${key} = params.${key};`) wrapperLines.push(` const ${key} = params.${key};`)
}) })
@@ -833,10 +931,12 @@ export async function POST(req: NextRequest) {
}) })
const ivmError = isolatedResult.error const ivmError = isolatedResult.error
// Adjust line number for prepended param destructuring in custom tools
let adjustedLine = ivmError.line let adjustedLine = ivmError.line
let adjustedLineContent = ivmError.lineContent let adjustedLineContent = ivmError.lineContent
if (prependedLineCount > 0 && ivmError.line !== undefined) { if (prependedLineCount > 0 && ivmError.line !== undefined) {
adjustedLine = Math.max(1, ivmError.line - prependedLineCount) adjustedLine = Math.max(1, ivmError.line - prependedLineCount)
// Get line content from original user code, not the prepended code
const codeLines = resolvedCode.split('\n') const codeLines = resolvedCode.split('\n')
if (adjustedLine <= codeLines.length) { if (adjustedLine <= codeLines.length) {
adjustedLineContent = codeLines[adjustedLine - 1]?.trim() adjustedLineContent = codeLines[adjustedLine - 1]?.trim()

View File

@@ -1,27 +0,0 @@
'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 (
<Link
href={href}
target={target}
rel={rel}
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 ${className}`}
>
{children}
</Link>
)
}

View File

@@ -2,7 +2,6 @@ import { BookOpen, Github, Rss } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import { inter } from '@/app/_styles/fonts/inter/inter' import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne' import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BrandedLink } from '@/app/changelog/components/branded-link'
import ChangelogList from '@/app/changelog/components/timeline-list' import ChangelogList from '@/app/changelog/components/timeline-list'
export interface ChangelogEntry { export interface ChangelogEntry {
@@ -67,24 +66,25 @@ export default async function ChangelogContent() {
<hr className='mt-6 border-border' /> <hr className='mt-6 border-border' />
<div className='mt-6 flex flex-wrap items-center gap-3 text-sm'> <div className='mt-6 flex flex-wrap items-center gap-3 text-sm'>
<BrandedLink <Link
href='https://github.com/simstudioai/sim/releases' href='https://github.com/simstudioai/sim/releases'
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
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]'
> >
<Github className='h-4 w-4' /> <Github className='h-4 w-4' />
View on GitHub View on GitHub
</BrandedLink> </Link>
<Link <Link
href='https://docs.sim.ai' href='https://docs.sim.ai'
className='inline-flex items-center gap-2 rounded-[10px] border border-border py-[6px] pr-[10px] pl-[12px] text-[15px] transition-all hover:bg-muted' className='inline-flex items-center gap-2 rounded-md border border-border px-3 py-1.5 hover:bg-muted'
> >
<BookOpen className='h-4 w-4' /> <BookOpen className='h-4 w-4' />
Documentation Documentation
</Link> </Link>
<Link <Link
href='/changelog.xml' href='/changelog.xml'
className='inline-flex items-center gap-2 rounded-[10px] border border-border py-[6px] pr-[10px] pl-[12px] text-[15px] transition-all hover:bg-muted' className='inline-flex items-center gap-2 rounded-md border border-border px-3 py-1.5 hover:bg-muted'
> >
<Rss className='h-4 w-4' /> <Rss className='h-4 w-4' />
RSS Feed RSS Feed

View File

@@ -117,7 +117,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const messagesContainerRef = useRef<HTMLDivElement>(null) const messagesContainerRef = useRef<HTMLDivElement>(null)
const [starCount, setStarCount] = useState('25.8k') const [starCount, setStarCount] = useState('25.1k')
const [conversationId, setConversationId] = useState('') const [conversationId, setConversationId] = useState('')
const [showScrollButton, setShowScrollButton] = useState(false) const [showScrollButton, setShowScrollButton] = useState(false)

View File

@@ -207,6 +207,7 @@ function TemplateCardInner({
isPannable={false} isPannable={false}
defaultZoom={0.8} defaultZoom={0.8}
fitPadding={0.2} fitPadding={0.2}
lightweight
/> />
) : ( ) : (
<div className='h-full w-full bg-[var(--surface-4)]' /> <div className='h-full w-full bg-[var(--surface-4)]' />

View File

@@ -16,8 +16,8 @@ import {
import { redactApiKeys } from '@/lib/core/security/redaction' import { redactApiKeys } from '@/lib/core/security/redaction'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { import {
BlockDetailsSidebar,
getLeftmostBlockId, getLeftmostBlockId,
PreviewEditor,
WorkflowPreview, WorkflowPreview,
} from '@/app/workspace/[workspaceId]/w/components/preview' } from '@/app/workspace/[workspaceId]/w/components/preview'
import { useExecutionSnapshot } from '@/hooks/queries/logs' import { useExecutionSnapshot } from '@/hooks/queries/logs'
@@ -248,10 +248,11 @@ export function ExecutionSnapshot({
cursorStyle='pointer' cursorStyle='pointer'
executedBlocks={blockExecutions} executedBlocks={blockExecutions}
selectedBlockId={pinnedBlockId} selectedBlockId={pinnedBlockId}
lightweight
/> />
</div> </div>
{pinnedBlockId && workflowState.blocks[pinnedBlockId] && ( {pinnedBlockId && workflowState.blocks[pinnedBlockId] && (
<PreviewEditor <BlockDetailsSidebar
block={workflowState.blocks[pinnedBlockId]} block={workflowState.blocks[pinnedBlockId]}
executionData={blockExecutions[pinnedBlockId]} executionData={blockExecutions[pinnedBlockId]}
allBlockExecutions={blockExecutions} allBlockExecutions={blockExecutions}

View File

@@ -213,6 +213,7 @@ function TemplateCardInner({
isPannable={false} isPannable={false}
defaultZoom={0.8} defaultZoom={0.8}
fitPadding={0.2} fitPadding={0.2}
lightweight
cursorStyle='pointer' cursorStyle='pointer'
/> />
) : ( ) : (

View File

@@ -4,10 +4,10 @@ import { Button, Copy, Tooltip, Trash2 } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' 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 { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useNotificationStore } from '@/stores/notifications'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' 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' import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const DEFAULT_DUPLICATE_OFFSET = { x: 50, y: 50 } const DEFAULT_DUPLICATE_OFFSET = { x: 50, y: 50 }
@@ -48,38 +48,29 @@ export const ActionBar = memo(
collaborativeBatchToggleBlockEnabled, collaborativeBatchToggleBlockEnabled,
collaborativeBatchToggleBlockHandles, collaborativeBatchToggleBlockHandles,
} = useCollaborativeWorkflow() } = useCollaborativeWorkflow()
const { setPendingSelection } = useWorkflowRegistry() const { activeWorkflowId, setPendingSelection } = useWorkflowRegistry()
const addNotification = useNotificationStore((s) => s.addNotification)
const handleDuplicateBlock = useCallback(() => { const handleDuplicateBlock = useCallback(() => {
const { copyBlocks, preparePasteData, activeWorkflowId } = useWorkflowRegistry.getState() const blocks = useWorkflowStore.getState().blocks
const existingBlocks = useWorkflowStore.getState().blocks const sourceBlock = blocks[blockId]
copyBlocks([blockId]) if (!sourceBlock) return
const pasteData = preparePasteData(DEFAULT_DUPLICATE_OFFSET) const newId = crypto.randomUUID()
if (!pasteData) return const newName = getUniqueBlockName(sourceBlock.name, blocks)
const subBlockValues =
useSubBlockStore.getState().workflowValues[activeWorkflowId || '']?.[blockId] || {}
const blocks = Object.values(pasteData.blocks) const { block, subBlockValues: filteredValues } = prepareDuplicateBlockState({
const validation = validateTriggerPaste(blocks, existingBlocks, 'duplicate') sourceBlock,
if (!validation.isValid) { newId,
addNotification({ newName,
level: 'error', positionOffset: DEFAULT_DUPLICATE_OFFSET,
message: validation.message!, subBlockValues,
workflowId: activeWorkflowId || undefined, })
})
return
}
setPendingSelection(blocks.map((b) => b.id)) setPendingSelection([newId])
collaborativeBatchAddBlocks( collaborativeBatchAddBlocks([block], [], {}, {}, { [newId]: filteredValues })
blocks, }, [blockId, activeWorkflowId, collaborativeBatchAddBlocks, setPendingSelection])
pasteData.edges,
pasteData.loops,
pasteData.parallels,
pasteData.subBlockValues
)
}, [blockId, addNotification, collaborativeBatchAddBlocks, setPendingSelection])
const { isEnabled, horizontalHandles, parentId, parentType } = useWorkflowStore( const { isEnabled, horizontalHandles, parentId, parentType } = useWorkflowStore(
useCallback( useCallback(

View File

@@ -138,24 +138,18 @@ export const Notifications = memo(function Notifications() {
}`} }`}
> >
<div className='flex h-full flex-col justify-between px-[8px] pt-[6px] pb-[8px]'> <div className='flex h-full flex-col justify-between px-[8px] pt-[6px] pb-[8px]'>
<div className='flex items-start gap-[8px]'> <div
<div className={`font-medium text-[12px] leading-[16px] ${
className={`min-w-0 flex-1 font-medium text-[12px] leading-[16px] ${ hasAction ? 'line-clamp-2' : 'line-clamp-4'
hasAction ? 'line-clamp-2' : 'line-clamp-4' }`}
}`} >
>
{notification.level === 'error' && (
<span className='mr-[6px] mb-[2.75px] inline-block h-[6px] w-[6px] rounded-[2px] bg-[var(--text-error)] align-middle' />
)}
{notification.message}
</div>
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
<Button <Button
variant='ghost' variant='ghost'
onClick={() => removeNotification(notification.id)} onClick={() => removeNotification(notification.id)}
aria-label='Dismiss notification' aria-label='Dismiss notification'
className='!p-1.5 -m-1.5 shrink-0' className='!p-1.5 -m-1.5 float-right ml-[16px]'
> >
<X className='h-3 w-3' /> <X className='h-3 w-3' />
</Button> </Button>
@@ -164,6 +158,10 @@ export const Notifications = memo(function Notifications() {
<Tooltip.Shortcut keys='⌘E'>Clear all</Tooltip.Shortcut> <Tooltip.Shortcut keys='⌘E'>Clear all</Tooltip.Shortcut>
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
{notification.level === 'error' && (
<span className='mr-[6px] mb-[2.75px] inline-block h-[6px] w-[6px] rounded-[2px] bg-[var(--text-error)] align-middle' />
)}
{notification.message}
</div> </div>
{hasAction && ( {hasAction && (
<Button <Button

View File

@@ -78,7 +78,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
mode, mode,
setMode, setMode,
isAborting, isAborting,
maskCredentialValue,
} = useCopilotStore() } = useCopilotStore()
const messageCheckpoints = isUser ? allMessageCheckpoints[message.id] || [] : [] const messageCheckpoints = isUser ? allMessageCheckpoints[message.id] || [] : []
@@ -211,10 +210,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
const isLastTextBlock = const isLastTextBlock =
index === message.contentBlocks!.length - 1 && block.type === 'text' index === message.contentBlocks!.length - 1 && block.type === 'text'
const parsed = parseSpecialTags(block.content) const parsed = parseSpecialTags(block.content)
// Mask credential IDs in the displayed content const cleanBlockContent = parsed.cleanContent.replace(/\n{3,}/g, '\n\n')
const cleanBlockContent = maskCredentialValue(
parsed.cleanContent.replace(/\n{3,}/g, '\n\n')
)
if (!cleanBlockContent.trim()) return null if (!cleanBlockContent.trim()) return null
@@ -242,7 +238,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
return ( return (
<div key={blockKey} className='w-full'> <div key={blockKey} className='w-full'>
<ThinkingBlock <ThinkingBlock
content={maskCredentialValue(block.content)} content={block.content}
isStreaming={isActivelyStreaming} isStreaming={isActivelyStreaming}
hasFollowingContent={hasFollowingContent} hasFollowingContent={hasFollowingContent}
hasSpecialTags={hasSpecialTags} hasSpecialTags={hasSpecialTags}
@@ -265,7 +261,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
} }
return null return null
}) })
}, [message.contentBlocks, isActivelyStreaming, parsedTags, isLastMessage, maskCredentialValue]) }, [message.contentBlocks, isActivelyStreaming, parsedTags, isLastMessage])
if (isUser) { if (isUser) {
return ( return (

View File

@@ -782,7 +782,6 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
const [isExpanded, setIsExpanded] = useState(true) const [isExpanded, setIsExpanded] = useState(true)
const [duration, setDuration] = useState(0) const [duration, setDuration] = useState(0)
const startTimeRef = useRef<number>(Date.now()) const startTimeRef = useRef<number>(Date.now())
const maskCredentialValue = useCopilotStore((s) => s.maskCredentialValue)
const wasStreamingRef = useRef(false) const wasStreamingRef = useRef(false)
// Only show streaming animations for current message // Only show streaming animations for current message
@@ -817,16 +816,14 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
currentText += parsed.cleanContent currentText += parsed.cleanContent
} else if (block.type === 'subagent_tool_call' && block.toolCall) { } else if (block.type === 'subagent_tool_call' && block.toolCall) {
if (currentText.trim()) { if (currentText.trim()) {
// Mask any credential IDs in the accumulated text before displaying segments.push({ type: 'text', content: currentText })
segments.push({ type: 'text', content: maskCredentialValue(currentText) })
currentText = '' currentText = ''
} }
segments.push({ type: 'tool', block }) segments.push({ type: 'tool', block })
} }
} }
if (currentText.trim()) { if (currentText.trim()) {
// Mask any credential IDs in the accumulated text before displaying segments.push({ type: 'text', content: currentText })
segments.push({ type: 'text', content: maskCredentialValue(currentText) })
} }
const allParsed = parseSpecialTags(allRawText) const allParsed = parseSpecialTags(allRawText)
@@ -955,7 +952,6 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
toolCall: CopilotToolCall toolCall: CopilotToolCall
}) { }) {
const blocks = useWorkflowStore((s) => s.blocks) const blocks = useWorkflowStore((s) => s.blocks)
const maskCredentialValue = useCopilotStore((s) => s.maskCredentialValue)
const cachedBlockInfoRef = useRef<Record<string, { name: string; type: string }>>({}) const cachedBlockInfoRef = useRef<Record<string, { name: string; type: string }>>({})
@@ -987,7 +983,6 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
title: string title: string
value: any value: any
isPassword?: boolean isPassword?: boolean
isCredential?: boolean
} }
interface BlockChange { interface BlockChange {
@@ -1096,7 +1091,6 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
title: subBlockConfig.title ?? subBlockConfig.id, title: subBlockConfig.title ?? subBlockConfig.id,
value, value,
isPassword: subBlockConfig.password === true, isPassword: subBlockConfig.password === true,
isCredential: subBlockConfig.type === 'oauth-input',
}) })
} }
} }
@@ -1178,15 +1172,8 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
{subBlocksToShow && subBlocksToShow.length > 0 && ( {subBlocksToShow && subBlocksToShow.length > 0 && (
<div className='border-[var(--border-1)] border-t px-2.5 py-1.5'> <div className='border-[var(--border-1)] border-t px-2.5 py-1.5'>
{subBlocksToShow.map((sb) => { {subBlocksToShow.map((sb) => {
// Mask password fields and credential IDs // Mask password fields like the canvas does
let displayValue: string const displayValue = sb.isPassword ? '•••' : getDisplayValue(sb.value)
if (sb.isPassword) {
displayValue = '•••'
} else {
// Get display value first, then mask any credential IDs that might be in it
const rawValue = getDisplayValue(sb.value)
displayValue = maskCredentialValue(rawValue)
}
return ( return (
<div key={sb.id} className='flex items-start gap-1.5 py-0.5 text-[11px]'> <div key={sb.id} className='flex items-start gap-1.5 py-0.5 text-[11px]'>
<span <span
@@ -1425,13 +1412,10 @@ function RunSkipButtons({
setIsProcessing(true) setIsProcessing(true)
setButtonsHidden(true) setButtonsHidden(true)
try { try {
// Add to auto-allowed list - this also executes all pending integration tools of this type // Add to auto-allowed list first
await addAutoAllowedTool(toolCall.name) await addAutoAllowedTool(toolCall.name)
// For client tools with interrupts (not integration tools), we still need to call handleRun // Then execute
// since executeIntegrationTool only works for server-side tools await handleRun(toolCall, setToolCallState, onStateChange, editedParams)
if (!isIntegrationTool(toolCall.name)) {
await handleRun(toolCall, setToolCallState, onStateChange, editedParams)
}
} finally { } finally {
setIsProcessing(false) setIsProcessing(false)
actionInProgressRef.current = false actionInProgressRef.current = false
@@ -1454,10 +1438,10 @@ function RunSkipButtons({
if (buttonsHidden) return null if (buttonsHidden) return null
// Show "Always Allow" for all tools that require confirmation // Hide "Always Allow" for integration tools (only show for client tools with interrupts)
const showAlwaysAllow = true const showAlwaysAllow = !isIntegrationTool(toolCall.name)
// Standardized buttons for all interrupt tools: Allow, Always Allow, Skip // Standardized buttons for all interrupt tools: Allow, (Always Allow for client tools only), Skip
return ( return (
<div className='mt-[10px] flex gap-[6px]'> <div className='mt-[10px] flex gap-[6px]'>
<Button onClick={onRun} disabled={isProcessing} variant='tertiary'> <Button onClick={onRun} disabled={isProcessing} variant='tertiary'>

View File

@@ -105,10 +105,10 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
isSendingMessage, isSendingMessage,
]) ])
/** Load auto-allowed tools once on mount - runs immediately, independent of workflow */ /** Load auto-allowed tools once on mount */
const hasLoadedAutoAllowedToolsRef = useRef(false) const hasLoadedAutoAllowedToolsRef = useRef(false)
useEffect(() => { useEffect(() => {
if (!hasLoadedAutoAllowedToolsRef.current) { if (hasMountedRef.current && !hasLoadedAutoAllowedToolsRef.current) {
hasLoadedAutoAllowedToolsRef.current = true hasLoadedAutoAllowedToolsRef.current = true
loadAutoAllowedTools().catch((err) => { loadAutoAllowedTools().catch((err) => {
logger.warn('[Copilot] Failed to load auto-allowed tools', err) logger.warn('[Copilot] Failed to load auto-allowed tools', err)

View File

@@ -18,8 +18,8 @@ import {
import { Skeleton } from '@/components/ui' import { Skeleton } from '@/components/ui'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils' import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import { import {
BlockDetailsSidebar,
getLeftmostBlockId, getLeftmostBlockId,
PreviewEditor,
WorkflowPreview, WorkflowPreview,
} from '@/app/workspace/[workspaceId]/w/components/preview' } from '@/app/workspace/[workspaceId]/w/components/preview'
import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows' import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows'
@@ -337,7 +337,7 @@ export function GeneralDeploy({
/> />
</div> </div>
{expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && ( {expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && (
<PreviewEditor <BlockDetailsSidebar
block={workflowToShow.blocks[expandedSelectedBlockId]} block={workflowToShow.blocks[expandedSelectedBlockId]}
workflowVariables={workflowToShow.variables} workflowVariables={workflowToShow.variables}
loops={workflowToShow.loops} loops={workflowToShow.loops}

View File

@@ -446,6 +446,7 @@ const OGCaptureContainer = forwardRef<HTMLDivElement>((_, ref) => {
isPannable={false} isPannable={false}
defaultZoom={0.8} defaultZoom={0.8}
fitPadding={0.2} fitPadding={0.2}
lightweight
/> />
</div> </div>
) )

View File

@@ -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 { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand' import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
import type { GenerationType } from '@/blocks/types' import type { GenerationType } from '@/blocks/types'
import { normalizeName } from '@/executor/constants'
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation' import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
import { useTagSelection } from '@/hooks/kb/use-tag-selection' import { useTagSelection } from '@/hooks/kb/use-tag-selection'
import { normalizeName } from '@/stores/workflows/utils'
const logger = createLogger('Code') const logger = createLogger('Code')

View File

@@ -1,7 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { isEqual } from 'lodash' import { isEqual } from 'lodash'
import { useReactFlow } from 'reactflow' import { useReactFlow } from 'reactflow'
import { useStoreWithEqualityFn } from 'zustand/traditional'
import { Combobox, type ComboboxOption } from '@/components/emcn/components' import { Combobox, type ComboboxOption } from '@/components/emcn/components'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
@@ -103,8 +102,7 @@ export const ComboBox = memo(function ComboBox({
[blockConfig?.subBlocks] [blockConfig?.subBlocks]
) )
const canonicalModeOverrides = blockState?.data?.canonicalModes const canonicalModeOverrides = blockState?.data?.canonicalModes
const dependencyValues = useStoreWithEqualityFn( const dependencyValues = useSubBlockStore(
useSubBlockStore,
useCallback( useCallback(
(state) => { (state) => {
if (dependsOnFields.length === 0 || !activeWorkflowId) return [] if (dependsOnFields.length === 0 || !activeWorkflowId) return []

View File

@@ -32,9 +32,9 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' } 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 { 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 { 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 { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
import { useTagSelection } from '@/hooks/kb/use-tag-selection' import { useTagSelection } from '@/hooks/kb/use-tag-selection'
import { normalizeName } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('ConditionInput') const logger = createLogger('ConditionInput')

View File

@@ -1,6 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { isEqual } from 'lodash' import { isEqual } from 'lodash'
import { useStoreWithEqualityFn } from 'zustand/traditional'
import { Badge } from '@/components/emcn' import { Badge } from '@/components/emcn'
import { Combobox, type ComboboxOption } from '@/components/emcn/components' import { Combobox, type ComboboxOption } from '@/components/emcn/components'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
@@ -101,8 +100,7 @@ export const Dropdown = memo(function Dropdown({
[blockConfig?.subBlocks] [blockConfig?.subBlocks]
) )
const canonicalModeOverrides = blockState?.data?.canonicalModes const canonicalModeOverrides = blockState?.data?.canonicalModes
const dependencyValues = useStoreWithEqualityFn( const dependencyValues = useSubBlockStore(
useSubBlockStore,
useCallback( useCallback(
(state) => { (state) => {
if (dependsOnFields.length === 0 || !activeWorkflowId) return [] if (dependsOnFields.length === 0 || !activeWorkflowId) return []

View File

@@ -2,8 +2,9 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { splitReferenceSegment } from '@/lib/workflows/sanitization/references' import { splitReferenceSegment } from '@/lib/workflows/sanitization/references'
import { normalizeName, REFERENCE } from '@/executor/constants' import { REFERENCE } from '@/executor/constants'
import { createCombinedPattern } from '@/executor/utils/reference-validation' import { createCombinedPattern } from '@/executor/utils/reference-validation'
import { normalizeName } from '@/stores/workflows/utils'
export interface HighlightContext { export interface HighlightContext {
accessiblePrefixes?: Set<string> accessiblePrefixes?: Set<string>

View File

@@ -34,4 +34,3 @@ export { Text } from './text/text'
export { TimeInput } from './time-input/time-input' export { TimeInput } from './time-input/time-input'
export { ToolInput } from './tool-input/tool-input' export { ToolInput } from './tool-input/tool-input'
export { VariablesInput } from './variables-input/variables-input' export { VariablesInput } from './variables-input/variables-input'
export { WorkflowSelectorInput } from './workflow-selector/workflow-selector-input'

View File

@@ -2,13 +2,12 @@ import { useMemo, useRef, useState } from 'react'
import { Badge, Input } from '@/components/emcn' import { Badge, Input } from '@/components/emcn'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { cn } from '@/lib/core/utils/cn' 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 { 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 { 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 { 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 { 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 { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useWorkflowState } from '@/hooks/queries/workflows' import { useWorkflowInputFields } from '@/hooks/queries/workflows'
/** /**
* Props for the InputMappingField component * Props for the InputMappingField component
@@ -71,11 +70,7 @@ export function InputMapping({
const overlayRefs = useRef<Map<string, HTMLDivElement>>(new Map()) const overlayRefs = useRef<Map<string, HTMLDivElement>>(new Map())
const workflowId = typeof selectedWorkflowId === 'string' ? selectedWorkflowId : undefined const workflowId = typeof selectedWorkflowId === 'string' ? selectedWorkflowId : undefined
const { data: workflowState, isLoading } = useWorkflowState(workflowId) const { data: childInputFields = [], isLoading } = useWorkflowInputFields(workflowId)
const childInputFields = useMemo(
() => (workflowState?.blocks ? extractInputFieldsFromBlocks(workflowState.blocks) : []),
[workflowState?.blocks]
)
const [collapsedFields, setCollapsedFields] = useState<Record<string, boolean>>({}) const [collapsedFields, setCollapsedFields] = useState<Record<string, boolean>>({})
const valueObj: Record<string, string> = useMemo(() => { const valueObj: Record<string, string> = useMemo(() => {

View File

@@ -1,12 +1,4 @@
import { import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { isEqual } from 'lodash' import { isEqual } from 'lodash'
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react' import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn' import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
@@ -390,137 +382,92 @@ export function MessagesInput({
textareaRefs.current[fieldId]?.focus() textareaRefs.current[fieldId]?.focus()
}, []) }, [])
const syncOverlay = useCallback((fieldId: string) => { const autoResizeTextarea = useCallback((fieldId: string) => {
const textarea = textareaRefs.current[fieldId] const textarea = textareaRefs.current[fieldId]
if (!textarea) return
const overlay = overlayRefs.current[fieldId] const overlay = overlayRefs.current[fieldId]
if (!textarea || !overlay) return
overlay.style.width = `${textarea.clientWidth}px` // If user has manually resized, respect their chosen height and only sync overlay.
overlay.scrollTop = textarea.scrollTop if (userResizedRef.current[fieldId]) {
overlay.scrollLeft = textarea.scrollLeft 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`
if (overlay) {
overlay.style.height = `${clampedHeight}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`
if (overlay) {
overlay.style.height = `${nextHeight}px`
}
}, []) }, [])
const autoResizeTextarea = useCallback( const handleResizeStart = useCallback((fieldId: string, e: React.MouseEvent<HTMLDivElement>) => {
(fieldId: string) => { e.preventDefault()
const textarea = textareaRefs.current[fieldId] e.stopPropagation()
const overlay = overlayRefs.current[fieldId]
if (!textarea) return
if (!textarea.value.trim()) { const textarea = textareaRefs.current[fieldId]
userResizedRef.current[fieldId] = false if (!textarea) return
const startHeight = textarea.offsetHeight || textarea.scrollHeight || MIN_TEXTAREA_HEIGHT_PX
isResizingRef.current = true
resizeStateRef.current = {
fieldId,
startY: e.clientY,
startHeight,
}
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 activeTextarea = textareaRefs.current[activeFieldId]
if (activeTextarea) {
activeTextarea.style.height = `${nextHeight}px`
} }
if (userResizedRef.current[fieldId]) { const overlay = overlayRefs.current[activeFieldId]
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) { if (overlay) {
overlay.style.height = `${height}px` overlay.style.height = `${nextHeight}px`
}
}
const handleMouseUp = () => {
if (resizeStateRef.current) {
const { fieldId: activeFieldId } = resizeStateRef.current
userResizedRef.current[activeFieldId] = true
} }
syncOverlay(fieldId) isResizingRef.current = false
}, resizeStateRef.current = null
[syncOverlay] document.removeEventListener('mousemove', handleMouseMove)
) document.removeEventListener('mouseup', handleMouseUp)
}
const handleResizeStart = useCallback( document.addEventListener('mousemove', handleMouseMove)
(fieldId: string, e: React.MouseEvent<HTMLDivElement>) => { document.addEventListener('mouseup', handleMouseUp)
e.preventDefault() }, [])
e.stopPropagation()
const textarea = textareaRefs.current[fieldId]
if (!textarea) return
const startHeight = textarea.offsetHeight || textarea.scrollHeight || MIN_TEXTAREA_HEIGHT_PX
isResizingRef.current = true
resizeStateRef.current = {
fieldId,
startY: e.clientY,
startHeight,
}
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 activeTextarea = textareaRefs.current[activeFieldId]
const overlay = overlayRefs.current[activeFieldId]
if (activeTextarea) {
activeTextarea.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
syncOverlay(activeFieldId)
}
isResizingRef.current = false
resizeStateRef.current = null
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
},
[syncOverlay]
)
useLayoutEffect(() => {
currentMessages.forEach((_, index) => {
autoResizeTextarea(`message-${index}`)
})
}, [currentMessages, autoResizeTextarea])
useEffect(() => { useEffect(() => {
const observers: ResizeObserver[] = [] currentMessages.forEach((_, index) => {
const fieldId = `message-${index}`
for (let i = 0; i < currentMessages.length; i++) { autoResizeTextarea(fieldId)
const fieldId = `message-${i}` })
const textarea = textareaRefs.current[fieldId] }, [currentMessages, autoResizeTextarea])
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 ( return (
<div className='flex w-full flex-col gap-[10px]'> <div className='flex w-full flex-col gap-[10px]'>
@@ -674,15 +621,19 @@ export function MessagesInput({
</div> </div>
{/* Content Input with overlay for variable highlighting */} {/* Content Input with overlay for variable highlighting */}
<div className='relative w-full overflow-hidden'> <div className='relative w-full'>
<textarea <textarea
ref={(el) => { ref={(el) => {
textareaRefs.current[fieldId] = el textareaRefs.current[fieldId] = el
}} }}
className='relative z-[2] m-0 box-border h-auto min-h-[80px] w-full resize-none overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-medium font-sans text-sm text-transparent leading-[1.5] caret-[var(--text-primary)] outline-none [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--text-muted)] focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed [&::-webkit-scrollbar]:hidden' className='allow-scroll box-border min-h-[80px] w-full resize-none whitespace-pre-wrap break-words border-none bg-transparent px-[8px] pt-[8px] font-[inherit] font-medium text-sm text-transparent leading-[inherit] caret-[var(--text-primary)] outline-none placeholder:text-[var(--text-muted)] focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed'
rows={3}
placeholder='Enter message content...' placeholder='Enter message content...'
value={message.content} value={message.content}
onChange={fieldHandlers.onChange} onChange={(e) => {
fieldHandlers.onChange(e)
autoResizeTextarea(fieldId)
}}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Tab' && !isPreview && !disabled) { if (e.key === 'Tab' && !isPreview && !disabled) {
e.preventDefault() e.preventDefault()
@@ -719,13 +670,12 @@ export function MessagesInput({
ref={(el) => { ref={(el) => {
overlayRefs.current[fieldId] = el overlayRefs.current[fieldId] = el
}} }}
className='pointer-events-none absolute top-0 left-0 z-[1] m-0 box-border w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.5] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden' className='scrollbar-none pointer-events-none absolute top-0 left-0 box-border w-full overflow-auto whitespace-pre-wrap break-words border-none bg-transparent px-[8px] pt-[8px] font-[inherit] font-medium text-[var(--text-primary)] text-sm leading-[inherit]'
> >
{formatDisplayText(message.content, { {formatDisplayText(message.content, {
accessiblePrefixes, accessiblePrefixes,
highlightAll: !accessiblePrefixes, highlightAll: !accessiblePrefixes,
})} })}
{message.content.endsWith('\n') && '\u200B'}
</div> </div>
{/* Env var dropdown for this message */} {/* Env var dropdown for this message */}
@@ -755,7 +705,7 @@ export function MessagesInput({
{!isPreview && !disabled && ( {!isPreview && !disabled && (
<div <div
className='absolute right-1 bottom-1 z-[3] flex h-4 w-4 cursor-ns-resize items-center justify-center rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] dark:bg-[var(--surface-5)]' className='absolute right-1 bottom-1 flex h-4 w-4 cursor-ns-resize items-center justify-center rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] dark:bg-[var(--surface-5)]'
onMouseDown={(e) => handleResizeStart(fieldId, e)} onMouseDown={(e) => handleResizeStart(fieldId, e)}
onDragStart={(e) => { onDragStart={(e) => {
e.preventDefault() e.preventDefault()

View File

@@ -35,11 +35,11 @@ import type {
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { getBlock } from '@/blocks' import { getBlock } from '@/blocks'
import type { BlockConfig } from '@/blocks/types' import type { BlockConfig } from '@/blocks/types'
import { normalizeName } from '@/executor/constants'
import type { Variable } from '@/stores/panel' import type { Variable } from '@/stores/panel'
import { useVariablesStore } from '@/stores/panel' import { useVariablesStore } from '@/stores/panel'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { normalizeName } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState } from '@/stores/workflows/workflow/types' import type { BlockState } from '@/stores/workflows/workflow/types'

View File

@@ -29,7 +29,6 @@ import {
type OAuthProvider, type OAuthProvider,
type OAuthService, type OAuthService,
} from '@/lib/oauth' } from '@/lib/oauth'
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { import {
CheckboxList, CheckboxList,
@@ -66,7 +65,7 @@ import { useForceRefreshMcpTools, useMcpServers, useStoredMcpTools } from '@/hoo
import { import {
useChildDeploymentStatus, useChildDeploymentStatus,
useDeployChildWorkflow, useDeployChildWorkflow,
useWorkflowState, useWorkflowInputFields,
useWorkflows, useWorkflows,
} from '@/hooks/queries/workflows' } from '@/hooks/queries/workflows'
import { usePermissionConfig } from '@/hooks/use-permission-config' import { usePermissionConfig } from '@/hooks/use-permission-config'
@@ -772,11 +771,7 @@ function WorkflowInputMapperSyncWrapper({
disabled: boolean disabled: boolean
workflowId: string workflowId: string
}) { }) {
const { data: workflowState, isLoading } = useWorkflowState(workflowId) const { data: inputFields = [], isLoading } = useWorkflowInputFields(workflowId)
const inputFields = useMemo(
() => (workflowState?.blocks ? extractInputFieldsFromBlocks(workflowState.blocks) : []),
[workflowState?.blocks]
)
const parsedValue = useMemo(() => { const parsedValue = useMemo(() => {
try { try {

View File

@@ -1,45 +0,0 @@
'use client'
import { useMemo } from 'react'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import type { SubBlockConfig } from '@/blocks/types'
import type { SelectorContext } from '@/hooks/selectors/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface WorkflowSelectorInputProps {
blockId: string
subBlock: SubBlockConfig
disabled?: boolean
isPreview?: boolean
previewValue?: string | null
}
export function WorkflowSelectorInput({
blockId,
subBlock,
disabled = false,
isPreview = false,
previewValue,
}: WorkflowSelectorInputProps) {
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const context: SelectorContext = useMemo(
() => ({
excludeWorkflowId: activeWorkflowId ?? undefined,
}),
[activeWorkflowId]
)
return (
<SelectorCombobox
blockId={blockId}
subBlock={subBlock}
selectorKey='sim.workflows'
selectorContext={context}
disabled={disabled}
isPreview={isPreview}
previewValue={previewValue}
placeholder={subBlock.placeholder || 'Select workflow...'}
/>
)
}

View File

@@ -2,7 +2,6 @@
import { useCallback, useMemo } from 'react' import { useCallback, useMemo } from 'react'
import { isEqual } from 'lodash' import { isEqual } from 'lodash'
import { useStoreWithEqualityFn } from 'zustand/traditional'
import { import {
buildCanonicalIndex, buildCanonicalIndex,
isNonEmptyValue, isNonEmptyValue,
@@ -152,7 +151,7 @@ export function useDependsOnGate(
// Get values for all dependency fields (both all and any) // Get values for all dependency fields (both all and any)
// Use isEqual to prevent re-renders when dependency values haven't actually changed // Use isEqual to prevent re-renders when dependency values haven't actually changed
const dependencyValuesMap = useStoreWithEqualityFn(useSubBlockStore, dependencySelector, isEqual) const dependencyValuesMap = useSubBlockStore(dependencySelector, isEqual)
const depsSatisfied = useMemo(() => { const depsSatisfied = useMemo(() => {
// Check all fields (AND logic) - all must be satisfied // Check all fields (AND logic) - all must be satisfied

View File

@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { isEqual } from 'lodash' import { isEqual } from 'lodash'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { useStoreWithEqualityFn } from 'zustand/traditional'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { getProviderFromModel } from '@/providers/utils' import { getProviderFromModel } from '@/providers/utils'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
@@ -59,8 +58,7 @@ export function useSubBlockValue<T = any>(
const streamingValueRef = useRef<T | null>(null) const streamingValueRef = useRef<T | null>(null)
const wasStreamingRef = useRef<boolean>(false) const wasStreamingRef = useRef<boolean>(false)
const storeValue = useStoreWithEqualityFn( const storeValue = useSubBlockStore(
useSubBlockStore,
useCallback( useCallback(
(state) => { (state) => {
// If the active workflow ID isn't available yet, return undefined so we can fall back to initialValue // If the active workflow ID isn't available yet, return undefined so we can fall back to initialValue
@@ -94,8 +92,7 @@ export function useSubBlockValue<T = any>(
// Always call this hook unconditionally - don't wrap it in a condition // Always call this hook unconditionally - don't wrap it in a condition
// Optimized: only re-render if model value actually changes // Optimized: only re-render if model value actually changes
const modelSubBlockValue = useStoreWithEqualityFn( const modelSubBlockValue = useSubBlockStore(
useSubBlockStore,
useCallback((state) => (blockId ? state.getValue(blockId, 'model') : null), [blockId]), useCallback((state) => (blockId ? state.getValue(blockId, 'model') : null), [blockId]),
(a, b) => a === b (a, b) => a === b
) )

View File

@@ -40,7 +40,6 @@ import {
TimeInput, TimeInput,
ToolInput, ToolInput,
VariablesInput, VariablesInput,
WorkflowSelectorInput,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components' } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
@@ -91,6 +90,7 @@ const isFieldRequired = (config: SubBlockConfig, subBlockValues?: Record<string,
if (!config.required) return false if (!config.required) return false
if (typeof config.required === 'boolean') return config.required if (typeof config.required === 'boolean') return config.required
// Helper function to evaluate a condition
const evalCond = ( const evalCond = (
cond: { cond: {
field: string field: string
@@ -132,6 +132,7 @@ const isFieldRequired = (config: SubBlockConfig, subBlockValues?: Record<string,
return match return match
} }
// If required is a condition object or function, evaluate it
const condition = typeof config.required === 'function' ? config.required() : config.required const condition = typeof config.required === 'function' ? config.required() : config.required
return evalCond(condition, subBlockValues || {}) return evalCond(condition, subBlockValues || {})
} }
@@ -377,6 +378,7 @@ function SubBlockComponent({
setIsValidJson(isValid) setIsValidJson(isValid)
} }
// Check if wand is enabled for this sub-block
const isWandEnabled = config.wandConfig?.enabled ?? false const isWandEnabled = config.wandConfig?.enabled ?? false
/** /**
@@ -436,6 +438,8 @@ function SubBlockComponent({
| null | null
| undefined | undefined
// Use dependsOn gating to compute final disabled state
// Only pass previewContextValues when in preview mode to avoid format mismatches
const { finalDisabled: gatedDisabled } = useDependsOnGate(blockId, config, { const { finalDisabled: gatedDisabled } = useDependsOnGate(blockId, config, {
disabled, disabled,
isPreview, isPreview,
@@ -865,17 +869,6 @@ function SubBlockComponent({
/> />
) )
case 'workflow-selector':
return (
<WorkflowSelectorInput
blockId={blockId}
subBlock={config}
disabled={isDisabled}
isPreview={isPreview}
previewValue={previewValue as string | null}
/>
)
case 'mcp-server-selector': case 'mcp-server-selector':
return ( return (
<McpServerSelector <McpServerSelector

View File

@@ -68,7 +68,7 @@ export function SubflowEditor({
<div className='flex flex-1 flex-col overflow-hidden pt-[0px]'> <div className='flex flex-1 flex-col overflow-hidden pt-[0px]'>
{/* Subflow Editor Section */} {/* Subflow Editor Section */}
<div ref={subBlocksRef} className='subblocks-section flex flex-1 flex-col overflow-hidden'> <div ref={subBlocksRef} className='subblocks-section flex flex-1 flex-col overflow-hidden'>
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[8px] pt-[9px] pb-[8px]'> <div className='flex-1 overflow-y-auto overflow-x-hidden px-[8px] pt-[5px] pb-[8px]'>
{/* Type Selection */} {/* Type Selection */}
<div> <div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'> <Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>

View File

@@ -2,18 +2,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { isEqual } from 'lodash' import { isEqual } from 'lodash'
import { import { BookOpen, Check, ChevronDown, ChevronUp, Pencil } from 'lucide-react'
BookOpen,
Check,
ChevronDown,
ChevronUp,
ExternalLink,
Loader2,
Pencil,
} from 'lucide-react'
import { useParams } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { useStoreWithEqualityFn } from 'zustand/traditional'
import { Button, Tooltip } from '@/components/emcn' import { Button, Tooltip } from '@/components/emcn'
import { import {
buildCanonicalIndex, buildCanonicalIndex,
@@ -38,10 +28,8 @@ import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/component
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config' import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils' import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
import type { SubBlockType } from '@/blocks/types' import type { SubBlockType } from '@/blocks/types'
import { useWorkflowState } from '@/hooks/queries/workflows'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePanelEditorStore } from '@/stores/panel' import { usePanelEditorStore } from '@/stores/panel'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -96,14 +84,6 @@ export function Editor() {
// Get subflow display properties from configs // Get subflow display properties from configs
const subflowConfig = isSubflow ? (currentBlock.type === 'loop' ? LoopTool : ParallelTool) : null const subflowConfig = isSubflow ? (currentBlock.type === 'loop' ? LoopTool : ParallelTool) : null
// Check if selected block is a workflow block
const isWorkflowBlock =
currentBlock && (currentBlock.type === 'workflow' || currentBlock.type === 'workflow_input')
// Get workspace ID from params
const params = useParams()
const workspaceId = params.workspaceId as string
// Refs for resize functionality // Refs for resize functionality
const subBlocksRef = useRef<HTMLDivElement>(null) const subBlocksRef = useRef<HTMLDivElement>(null)
@@ -119,8 +99,7 @@ export function Editor() {
currentWorkflow.isSnapshotView currentWorkflow.isSnapshotView
) )
const blockSubBlockValues = useStoreWithEqualityFn( const blockSubBlockValues = useSubBlockStore(
useSubBlockStore,
useCallback( useCallback(
(state) => { (state) => {
if (!activeWorkflowId || !currentBlockId) return EMPTY_SUBBLOCK_VALUES if (!activeWorkflowId || !currentBlockId) return EMPTY_SUBBLOCK_VALUES
@@ -273,11 +252,11 @@ export function Editor() {
// Trigger rename mode when signaled from context menu // Trigger rename mode when signaled from context menu
useEffect(() => { useEffect(() => {
if (shouldFocusRename && currentBlock) { if (shouldFocusRename && currentBlock && !isSubflow) {
handleStartRename() handleStartRename()
setShouldFocusRename(false) setShouldFocusRename(false)
} }
}, [shouldFocusRename, currentBlock, handleStartRename, setShouldFocusRename]) }, [shouldFocusRename, currentBlock, isSubflow, handleStartRename, setShouldFocusRename])
/** /**
* Handles opening documentation link in a new secure tab. * Handles opening documentation link in a new secure tab.
@@ -289,22 +268,6 @@ export function Editor() {
} }
} }
// Get child workflow ID for workflow blocks
const childWorkflowId = isWorkflowBlock ? blockSubBlockValues?.workflowId : null
// Fetch child workflow state for preview (only for workflow blocks with a selected workflow)
const { data: childWorkflowState, isLoading: isLoadingChildWorkflow } =
useWorkflowState(childWorkflowId)
/**
* Handles opening the child workflow in a new tab.
*/
const handleOpenChildWorkflow = useCallback(() => {
if (childWorkflowId && workspaceId) {
window.open(`/workspace/${workspaceId}/w/${childWorkflowId}`, '_blank', 'noopener,noreferrer')
}
}, [childWorkflowId, workspaceId])
// Determine if connections are at minimum height (collapsed state) // Determine if connections are at minimum height (collapsed state)
const isConnectionsAtMinHeight = connectionsHeight <= 35 const isConnectionsAtMinHeight = connectionsHeight <= 35
@@ -357,7 +320,7 @@ export function Editor() {
</div> </div>
<div className='flex shrink-0 items-center gap-[8px]'> <div className='flex shrink-0 items-center gap-[8px]'>
{/* Rename button */} {/* Rename button */}
{currentBlock && ( {currentBlock && !isSubflow && (
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
<Button <Button
@@ -443,66 +406,7 @@ export function Editor() {
className='subblocks-section flex flex-1 flex-col overflow-hidden' className='subblocks-section flex flex-1 flex-col overflow-hidden'
> >
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[8px] pt-[12px] pb-[8px] [overflow-anchor:none]'> <div className='flex-1 overflow-y-auto overflow-x-hidden px-[8px] pt-[12px] pb-[8px] [overflow-anchor:none]'>
{/* Workflow Preview - only for workflow blocks with a selected child workflow */} {subBlocks.length === 0 ? (
{isWorkflowBlock && childWorkflowId && (
<>
<div className='subblock-content flex flex-col gap-[9.5px]'>
<div className='pl-[2px] font-medium text-[13px] text-[var(--text-primary)] leading-none'>
Workflow Preview
</div>
<div className='relative h-[160px] overflow-hidden rounded-[4px] border border-[var(--border)]'>
{isLoadingChildWorkflow ? (
<div className='flex h-full items-center justify-center bg-[var(--surface-3)]'>
<Loader2 className='h-5 w-5 animate-spin text-[var(--text-tertiary)]' />
</div>
) : childWorkflowState ? (
<>
<div className='[&_*:active]:!cursor-grabbing [&_*]:!cursor-grab [&_.react-flow__handle]:!hidden h-full w-full'>
<WorkflowPreview
workflowState={childWorkflowState}
height={160}
width='100%'
isPannable={true}
defaultZoom={0.6}
fitPadding={0.15}
cursorStyle='grab'
/>
</div>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={handleOpenChildWorkflow}
className='absolute right-[6px] bottom-[6px] z-10 h-[24px] w-[24px] cursor-pointer border border-[var(--border)] bg-[var(--surface-2)] p-0 hover:bg-[var(--surface-4)]'
>
<ExternalLink className='h-[12px] w-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Open workflow</Tooltip.Content>
</Tooltip.Root>
</>
) : (
<div className='flex h-full items-center justify-center bg-[var(--surface-3)]'>
<span className='text-[13px] text-[var(--text-tertiary)]'>
Unable to load preview
</span>
</div>
)}
</div>
</div>
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
</>
)}
{subBlocks.length === 0 && !isWorkflowBlock ? (
<div className='flex h-full items-center justify-center text-center text-[#8D8D8D] text-[13px]'> <div className='flex h-full items-center justify-center text-center text-[#8D8D8D] text-[13px]'>
This block has no subblocks This block has no subblocks
</div> </div>

View File

@@ -1,4 +1,5 @@
import { useShallow } from 'zustand/react/shallow' import { useCallback } from 'react'
import { shallow } from 'zustand/shallow'
import { useWorkflowDiffStore } from '@/stores/workflow-diff' import { useWorkflowDiffStore } from '@/stores/workflow-diff'
import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -12,26 +13,35 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
*/ */
export function useEditorBlockProperties(blockId: string | null, isSnapshotView: boolean) { export function useEditorBlockProperties(blockId: string | null, isSnapshotView: boolean) {
const normalBlockProps = useWorkflowStore( const normalBlockProps = useWorkflowStore(
useShallow((state) => { useCallback(
if (!blockId) return { advancedMode: false, triggerMode: false } (state) => {
const block = state.blocks?.[blockId] if (!blockId) return { advancedMode: false, triggerMode: false }
return { const block = state.blocks?.[blockId]
advancedMode: block?.advancedMode ?? false, return {
triggerMode: block?.triggerMode ?? false, advancedMode: block?.advancedMode ?? false,
} triggerMode: block?.triggerMode ?? false,
}) }
},
[blockId]
),
shallow
) )
const baselineBlockProps = useWorkflowDiffStore( const baselineBlockProps = useWorkflowDiffStore(
useShallow((state) => { useCallback(
if (!blockId) return { advancedMode: false, triggerMode: false } (state) => {
const block = state.baselineWorkflow?.blocks?.[blockId] if (!blockId) return { advancedMode: false, triggerMode: false }
return { const block = state.baselineWorkflow?.blocks?.[blockId]
advancedMode: block?.advancedMode ?? false, return {
triggerMode: block?.triggerMode ?? false, advancedMode: block?.advancedMode ?? false,
} triggerMode: block?.triggerMode ?? false,
}) }
},
[blockId]
),
shallow
) )
// Use the appropriate props based on view mode
return isSnapshotView ? baselineBlockProps : normalBlockProps return isSnapshotView ? baselineBlockProps : normalBlockProps
} }

View File

@@ -3,7 +3,6 @@ import { createLogger } from '@sim/logger'
import { isEqual } from 'lodash' import { isEqual } from 'lodash'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow' import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
import { useStoreWithEqualityFn } from 'zustand/traditional'
import { Badge, Tooltip } from '@/components/emcn' import { Badge, Tooltip } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -527,8 +526,7 @@ const SubBlockRow = memo(function SubBlockRow({
* Subscribe only to variables for this workflow to avoid re-renders from other workflows. * Subscribe only to variables for this workflow to avoid re-renders from other workflows.
* Uses isEqual for deep comparison since Object.fromEntries creates a new object each time. * Uses isEqual for deep comparison since Object.fromEntries creates a new object each time.
*/ */
const workflowVariables = useStoreWithEqualityFn( const workflowVariables = useVariablesStore(
useVariablesStore,
useCallback( useCallback(
(state) => { (state) => {
if (!workflowId) return {} if (!workflowId) return {}
@@ -731,8 +729,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
const isStarterBlock = type === 'starter' const isStarterBlock = type === 'starter'
const isWebhookTriggerBlock = type === 'webhook' || type === 'generic_webhook' const isWebhookTriggerBlock = type === 'webhook' || type === 'generic_webhook'
const blockSubBlockValues = useStoreWithEqualityFn( const blockSubBlockValues = useSubBlockStore(
useSubBlockStore,
useCallback( useCallback(
(state) => { (state) => {
if (!activeWorkflowId) return EMPTY_SUBBLOCK_VALUES if (!activeWorkflowId) return EMPTY_SUBBLOCK_VALUES

View File

@@ -685,7 +685,7 @@ interface WorkflowVariable {
value: unknown value: unknown
} }
interface PreviewEditorProps { interface BlockDetailsSidebarProps {
block: BlockState block: BlockState
executionData?: ExecutionData executionData?: ExecutionData
/** All block execution data for resolving variable references */ /** All block execution data for resolving variable references */
@@ -722,7 +722,7 @@ const DEFAULT_CONNECTIONS_HEIGHT = 150
/** /**
* Readonly sidebar panel showing block configuration using SubBlock components. * Readonly sidebar panel showing block configuration using SubBlock components.
*/ */
function PreviewEditorContent({ function BlockDetailsSidebarContent({
block, block,
executionData, executionData,
allBlockExecutions, allBlockExecutions,
@@ -732,7 +732,7 @@ function PreviewEditorContent({
parallels, parallels,
isExecutionMode = false, isExecutionMode = false,
onClose, onClose,
}: PreviewEditorProps) { }: BlockDetailsSidebarProps) {
// Convert Record<string, Variable> to Array<Variable> for iteration // Convert Record<string, Variable> to Array<Variable> for iteration
const normalizedWorkflowVariables = useMemo(() => { const normalizedWorkflowVariables = useMemo(() => {
if (!workflowVariables) return [] if (!workflowVariables) return []
@@ -998,22 +998,6 @@ function PreviewEditorContent({
}) })
}, [extractedRefs.envVars]) }, [extractedRefs.envVars])
const rawValues = useMemo(() => {
return Object.entries(subBlockValues).reduce<Record<string, unknown>>((acc, [key, entry]) => {
if (entry && typeof entry === 'object' && 'value' in entry) {
acc[key] = (entry as { value: unknown }).value
} else {
acc[key] = entry
}
return acc
}, {})
}, [subBlockValues])
const canonicalIndex = useMemo(
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
[blockConfig?.subBlocks]
)
// Check if this is a subflow block (loop or parallel) // Check if this is a subflow block (loop or parallel)
const isSubflow = block.type === 'loop' || block.type === 'parallel' const isSubflow = block.type === 'loop' || block.type === 'parallel'
const loopConfig = block.type === 'loop' ? loops?.[block.id] : undefined const loopConfig = block.type === 'loop' ? loops?.[block.id] : undefined
@@ -1095,6 +1079,21 @@ function PreviewEditorContent({
) )
} }
const rawValues = useMemo(() => {
return Object.entries(subBlockValues).reduce<Record<string, unknown>>((acc, [key, entry]) => {
if (entry && typeof entry === 'object' && 'value' in entry) {
acc[key] = (entry as { value: unknown }).value
} else {
acc[key] = entry
}
return acc
}, {})
}, [subBlockValues])
const canonicalIndex = useMemo(
() => buildCanonicalIndex(blockConfig.subBlocks),
[blockConfig.subBlocks]
)
const canonicalModeOverrides = block.data?.canonicalModes const canonicalModeOverrides = block.data?.canonicalModes
const effectiveAdvanced = const effectiveAdvanced =
(block.advancedMode ?? false) || (block.advancedMode ?? false) ||
@@ -1372,12 +1371,12 @@ function PreviewEditorContent({
} }
/** /**
* Preview editor wrapped in ReactFlowProvider for hook compatibility. * Block details sidebar wrapped in ReactFlowProvider for hook compatibility.
*/ */
export function PreviewEditor(props: PreviewEditorProps) { export function BlockDetailsSidebar(props: BlockDetailsSidebarProps) {
return ( return (
<ReactFlowProvider> <ReactFlowProvider>
<PreviewEditorContent {...props} /> <BlockDetailsSidebarContent {...props} />
</ReactFlowProvider> </ReactFlowProvider>
) )
} }

View File

@@ -21,9 +21,11 @@ interface WorkflowPreviewBlockData {
} }
/** /**
* Preview block component for workflow visualization. * Lightweight block component for workflow previews.
* Renders block header, subblocks skeleton, and handles without * Renders block header, dummy subblocks skeleton, and handles.
* hooks, store subscriptions, or interactive features. * Respects horizontalHandles and enabled state from workflow.
* No heavy hooks, store subscriptions, or interactive features.
* Used in template cards and other preview contexts for performance.
*/ */
function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>) { function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>) {
const { const {

View File

@@ -15,9 +15,10 @@ interface WorkflowPreviewSubflowData {
} }
/** /**
* Preview subflow component for workflow visualization. * Lightweight subflow component for workflow previews.
* Renders loop/parallel containers without hooks, store subscriptions, * Matches the styling of the actual SubflowNodeComponent but without
* or interactive features. * hooks, store subscriptions, or interactive features.
* Used in template cards and other preview contexts for performance.
*/ */
function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowData>) { function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowData>) {
const { name, width = 500, height = 300, kind, isPreviewSelected = false } = data const { name, width = 500, height = 300, kind, isPreviewSelected = false } = data

View File

@@ -1,2 +1,2 @@
export { PreviewEditor } from './components/preview-editor' export { BlockDetailsSidebar } from './components/block-details-sidebar'
export { getLeftmostBlockId, WorkflowPreview } from './preview' export { getLeftmostBlockId, WorkflowPreview } from './preview'

View File

@@ -15,10 +15,14 @@ import 'reactflow/dist/style.css'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions' import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge' import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { WorkflowPreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/block' import { WorkflowPreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/block'
import { WorkflowPreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/subflow' import { WorkflowPreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/subflow'
import { getBlock } from '@/blocks'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowPreview') const logger = createLogger('WorkflowPreview')
@@ -130,6 +134,8 @@ interface WorkflowPreviewProps {
onNodeContextMenu?: (blockId: string, mousePosition: { x: number; y: number }) => void onNodeContextMenu?: (blockId: string, mousePosition: { x: number; y: number }) => void
/** Callback when the canvas (empty area) is clicked */ /** Callback when the canvas (empty area) is clicked */
onPaneClick?: () => void onPaneClick?: () => void
/** Use lightweight blocks for better performance in template cards */
lightweight?: boolean
/** Cursor style to show when hovering the canvas */ /** Cursor style to show when hovering the canvas */
cursorStyle?: 'default' | 'pointer' | 'grab' cursorStyle?: 'default' | 'pointer' | 'grab'
/** Map of executed block IDs to their status for highlighting the execution path */ /** Map of executed block IDs to their status for highlighting the execution path */
@@ -139,10 +145,19 @@ interface WorkflowPreviewProps {
} }
/** /**
* Preview node types using minimal components without hooks or store subscriptions. * Full node types with interactive WorkflowBlock for detailed previews
* This prevents interaction issues while allowing canvas panning and node clicking.
*/ */
const previewNodeTypes: NodeTypes = { const fullNodeTypes: NodeTypes = {
workflowBlock: WorkflowBlock,
noteBlock: NoteBlock,
subflowNode: SubflowNodeComponent,
}
/**
* Lightweight node types for template cards and other high-volume previews.
* Uses minimal components without hooks or store subscriptions.
*/
const lightweightNodeTypes: NodeTypes = {
workflowBlock: WorkflowPreviewBlock, workflowBlock: WorkflowPreviewBlock,
noteBlock: WorkflowPreviewBlock, noteBlock: WorkflowPreviewBlock,
subflowNode: WorkflowPreviewSubflow, subflowNode: WorkflowPreviewSubflow,
@@ -157,19 +172,17 @@ const edgeTypes: EdgeTypes = {
interface FitViewOnChangeProps { interface FitViewOnChangeProps {
nodeIds: string nodeIds: string
fitPadding: number fitPadding: number
containerRef: React.RefObject<HTMLDivElement | null>
} }
/** /**
* Helper component that calls fitView when the set of nodes changes or when the container resizes. * Helper component that calls fitView when the set of nodes changes.
* Only triggers on actual node additions/removals, not on selection changes. * Only triggers on actual node additions/removals, not on selection changes.
* Must be rendered inside ReactFlowProvider. * Must be rendered inside ReactFlowProvider.
*/ */
function FitViewOnChange({ nodeIds, fitPadding, containerRef }: FitViewOnChangeProps) { function FitViewOnChange({ nodeIds, fitPadding }: FitViewOnChangeProps) {
const { fitView } = useReactFlow() const { fitView } = useReactFlow()
const lastNodeIdsRef = useRef<string | null>(null) const lastNodeIdsRef = useRef<string | null>(null)
// Fit view when nodes change
useEffect(() => { useEffect(() => {
if (!nodeIds.length) return if (!nodeIds.length) return
const shouldFit = lastNodeIdsRef.current !== nodeIds const shouldFit = lastNodeIdsRef.current !== nodeIds
@@ -182,27 +195,6 @@ function FitViewOnChange({ nodeIds, fitPadding, containerRef }: FitViewOnChangeP
return () => clearTimeout(timeoutId) return () => clearTimeout(timeoutId)
}, [nodeIds, fitPadding, fitView]) }, [nodeIds, fitPadding, fitView])
// Fit view when container resizes (debounced to avoid excessive calls during drag)
useEffect(() => {
const container = containerRef.current
if (!container) return
let timeoutId: ReturnType<typeof setTimeout> | null = null
const resizeObserver = new ResizeObserver(() => {
if (timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
fitView({ padding: fitPadding, duration: 150 })
}, 100)
})
resizeObserver.observe(container)
return () => {
if (timeoutId) clearTimeout(timeoutId)
resizeObserver.disconnect()
}
}, [containerRef, fitPadding, fitView])
return null return null
} }
@@ -218,12 +210,12 @@ export function WorkflowPreview({
onNodeClick, onNodeClick,
onNodeContextMenu, onNodeContextMenu,
onPaneClick, onPaneClick,
lightweight = false,
cursorStyle = 'grab', cursorStyle = 'grab',
executedBlocks, executedBlocks,
selectedBlockId, selectedBlockId,
}: WorkflowPreviewProps) { }: WorkflowPreviewProps) {
const containerRef = useRef<HTMLDivElement>(null) const nodeTypes = lightweight ? lightweightNodeTypes : fullNodeTypes
const nodeTypes = previewNodeTypes
const isValidWorkflowState = workflowState?.blocks && workflowState.edges const isValidWorkflowState = workflowState?.blocks && workflowState.edges
const blocksStructure = useMemo(() => { const blocksStructure = useMemo(() => {
@@ -293,28 +285,119 @@ export function WorkflowPreview({
const absolutePosition = calculateAbsolutePosition(block, workflowState.blocks) const absolutePosition = calculateAbsolutePosition(block, workflowState.blocks)
// Handle loop/parallel containers if (lightweight) {
if (block.type === 'loop' || block.type === 'parallel') { if (block.type === 'loop' || block.type === 'parallel') {
const isSelected = selectedBlockId === blockId
const dimensions = calculateContainerDimensions(blockId, workflowState.blocks)
nodeArray.push({
id: blockId,
type: 'subflowNode',
position: absolutePosition,
draggable: false,
data: {
name: block.name,
width: dimensions.width,
height: dimensions.height,
kind: block.type as 'loop' | 'parallel',
isPreviewSelected: isSelected,
},
})
return
}
const isSelected = selectedBlockId === blockId
let lightweightExecutionStatus: ExecutionStatus | undefined
if (executedBlocks) {
const blockExecution = executedBlocks[blockId]
if (blockExecution) {
if (blockExecution.status === 'error') {
lightweightExecutionStatus = 'error'
} else if (blockExecution.status === 'success') {
lightweightExecutionStatus = 'success'
} else {
lightweightExecutionStatus = 'not-executed'
}
} else {
lightweightExecutionStatus = 'not-executed'
}
}
nodeArray.push({
id: blockId,
type: 'workflowBlock',
position: absolutePosition,
draggable: false,
// Blocks inside subflows need higher z-index to appear above the container
zIndex: block.data?.parentId ? 10 : undefined,
data: {
type: block.type,
name: block.name,
isTrigger: block.triggerMode === true,
horizontalHandles: block.horizontalHandles ?? false,
enabled: block.enabled ?? true,
isPreviewSelected: isSelected,
executionStatus: lightweightExecutionStatus,
},
})
return
}
if (block.type === 'loop') {
const isSelected = selectedBlockId === blockId const isSelected = selectedBlockId === blockId
const dimensions = calculateContainerDimensions(blockId, workflowState.blocks) const dimensions = calculateContainerDimensions(blockId, workflowState.blocks)
nodeArray.push({ nodeArray.push({
id: blockId, id: blockId,
type: 'subflowNode', type: 'subflowNode',
position: absolutePosition, position: absolutePosition,
parentId: block.data?.parentId,
extent: block.data?.extent || undefined,
draggable: false, draggable: false,
data: { data: {
...block.data,
name: block.name, name: block.name,
width: dimensions.width, width: dimensions.width,
height: dimensions.height, height: dimensions.height,
kind: block.type as 'loop' | 'parallel', state: 'valid',
isPreview: true,
isPreviewSelected: isSelected, isPreviewSelected: isSelected,
kind: 'loop',
}, },
}) })
return return
} }
// Handle regular blocks if (block.type === 'parallel') {
const isSelected = selectedBlockId === blockId const isSelected = selectedBlockId === blockId
const dimensions = calculateContainerDimensions(blockId, workflowState.blocks)
nodeArray.push({
id: blockId,
type: 'subflowNode',
position: absolutePosition,
parentId: block.data?.parentId,
extent: block.data?.extent || undefined,
draggable: false,
data: {
...block.data,
name: block.name,
width: dimensions.width,
height: dimensions.height,
state: 'valid',
isPreview: true,
isPreviewSelected: isSelected,
kind: 'parallel',
},
})
return
}
const blockConfig = getBlock(block.type)
if (!blockConfig) {
logger.error(`No configuration found for block type: ${block.type}`, { blockId })
return
}
const nodeType = block.type === 'note' ? 'noteBlock' : 'workflowBlock'
let executionStatus: ExecutionStatus | undefined let executionStatus: ExecutionStatus | undefined
if (executedBlocks) { if (executedBlocks) {
@@ -332,20 +415,24 @@ export function WorkflowPreview({
} }
} }
const isSelected = selectedBlockId === blockId
nodeArray.push({ nodeArray.push({
id: blockId, id: blockId,
type: 'workflowBlock', type: nodeType,
position: absolutePosition, position: absolutePosition,
draggable: false, draggable: false,
// Blocks inside subflows need higher z-index to appear above the container // Blocks inside subflows need higher z-index to appear above the container
zIndex: block.data?.parentId ? 10 : undefined, zIndex: block.data?.parentId ? 10 : undefined,
data: { data: {
type: block.type, type: block.type,
config: blockConfig,
name: block.name, name: block.name,
isTrigger: block.triggerMode === true, blockState: block,
horizontalHandles: block.horizontalHandles ?? false, canEdit: false,
enabled: block.enabled ?? true, isPreview: true,
isPreviewSelected: isSelected, isPreviewSelected: isSelected,
subBlockValues: block.subBlocks ?? {},
executionStatus, executionStatus,
}, },
}) })
@@ -358,6 +445,7 @@ export function WorkflowPreview({
parallelsStructure, parallelsStructure,
workflowState.blocks, workflowState.blocks,
isValidWorkflowState, isValidWorkflowState,
lightweight,
executedBlocks, executedBlocks,
selectedBlockId, selectedBlockId,
]) ])
@@ -415,7 +503,6 @@ export function WorkflowPreview({
return ( return (
<ReactFlowProvider> <ReactFlowProvider>
<div <div
ref={containerRef}
style={{ height, width, backgroundColor: 'var(--bg)' }} style={{ height, width, backgroundColor: 'var(--bg)' }}
className={cn('preview-mode', onNodeClick && 'interactive-nodes', className)} className={cn('preview-mode', onNodeClick && 'interactive-nodes', className)}
> >
@@ -426,20 +513,6 @@ export function WorkflowPreview({
.preview-mode .react-flow__selectionpane { cursor: ${cursorStyle} !important; } .preview-mode .react-flow__selectionpane { cursor: ${cursorStyle} !important; }
.preview-mode .react-flow__renderer { cursor: ${cursorStyle}; } .preview-mode .react-flow__renderer { cursor: ${cursorStyle}; }
/* Active/grabbing cursor when dragging */
${
cursorStyle === 'grab'
? `
.preview-mode .react-flow:active { cursor: grabbing; }
.preview-mode .react-flow__pane:active { cursor: grabbing !important; }
.preview-mode .react-flow__selectionpane:active { cursor: grabbing !important; }
.preview-mode .react-flow__renderer:active { cursor: grabbing; }
.preview-mode .react-flow__node:active { cursor: grabbing !important; }
.preview-mode .react-flow__node:active * { cursor: grabbing !important; }
`
: ''
}
/* Node cursor - pointer on nodes when onNodeClick is provided */ /* Node cursor - pointer on nodes when onNodeClick is provided */
.preview-mode.interactive-nodes .react-flow__node { cursor: pointer !important; } .preview-mode.interactive-nodes .react-flow__node { cursor: pointer !important; }
.preview-mode.interactive-nodes .react-flow__node > div { cursor: pointer !important; } .preview-mode.interactive-nodes .react-flow__node > div { cursor: pointer !important; }
@@ -487,11 +560,7 @@ export function WorkflowPreview({
} }
onPaneClick={onPaneClick} onPaneClick={onPaneClick}
/> />
<FitViewOnChange <FitViewOnChange nodeIds={blocksStructure.ids} fitPadding={fitPadding} />
nodeIds={blocksStructure.ids}
fitPadding={fitPadding}
containerRef={containerRef}
/>
</div> </div>
</ReactFlowProvider> </ReactFlowProvider>
) )

View File

@@ -11,7 +11,7 @@ import { useSidebarStore } from '@/stores/sidebar/store'
* Avatar display configuration for responsive layout. * Avatar display configuration for responsive layout.
*/ */
const AVATAR_CONFIG = { const AVATAR_CONFIG = {
MIN_COUNT: 4, MIN_COUNT: 3,
MAX_COUNT: 12, MAX_COUNT: 12,
WIDTH_PER_AVATAR: 20, WIDTH_PER_AVATAR: 20,
} as const } as const
@@ -106,9 +106,7 @@ export function Avatars({ workflowId }: AvatarsProps) {
}, [presenceUsers, currentWorkflowId, workflowId, currentSocketId]) }, [presenceUsers, currentWorkflowId, workflowId, currentSocketId])
/** /**
* Calculate visible users and overflow count. * Calculate visible users and overflow count
* Shows up to maxVisible avatars, with overflow indicator for any remaining.
* Users are reversed so new avatars appear on the left (keeping right side stable).
*/ */
const { visibleUsers, overflowCount } = useMemo(() => { const { visibleUsers, overflowCount } = useMemo(() => {
if (workflowUsers.length === 0) { if (workflowUsers.length === 0) {
@@ -118,8 +116,7 @@ export function Avatars({ workflowId }: AvatarsProps) {
const visible = workflowUsers.slice(0, maxVisible) const visible = workflowUsers.slice(0, maxVisible)
const overflow = Math.max(0, workflowUsers.length - maxVisible) const overflow = Math.max(0, workflowUsers.length - maxVisible)
// Reverse so rightmost avatars stay stable as new ones are revealed on the left return { visibleUsers: visible, overflowCount: overflow }
return { visibleUsers: [...visible].reverse(), overflowCount: overflow }
}, [workflowUsers, maxVisible]) }, [workflowUsers, maxVisible])
if (visibleUsers.length === 0) { if (visibleUsers.length === 0) {
@@ -142,8 +139,9 @@ export function Avatars({ workflowId }: AvatarsProps) {
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
)} )}
{visibleUsers.map((user, index) => ( {visibleUsers.map((user, index) => (
<UserAvatar key={user.socketId} user={user} index={index} /> <UserAvatar key={user.socketId} user={user} index={overflowCount > 0 ? index + 1 : index} />
))} ))}
</div> </div>
) )

View File

@@ -347,7 +347,7 @@ export function WorkflowItem({
) : ( ) : (
<div <div
className={clsx( className={clsx(
'min-w-0 truncate font-medium', 'min-w-0 flex-1 truncate font-medium',
active active
? 'text-[var(--text-primary)]' ? 'text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'

View File

@@ -1,5 +1,30 @@
import { createLogger } from '@sim/logger'
import { WorkflowIcon } from '@/components/icons' import { WorkflowIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types' import type { BlockConfig } from '@/blocks/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('WorkflowBlock')
// Helper function to get available workflows for the dropdown
const getAvailableWorkflows = (): Array<{ label: string; id: string }> => {
try {
const { workflows, activeWorkflowId } = useWorkflowRegistry.getState()
// Filter out the current workflow to prevent recursion
const availableWorkflows = Object.entries(workflows)
.filter(([id]) => id !== activeWorkflowId)
.map(([id, workflow]) => ({
label: workflow.name || `Workflow ${id.slice(0, 8)}`,
id: id,
}))
.sort((a, b) => a.label.localeCompare(b.label))
return availableWorkflows
} catch (error) {
logger.error('Error getting available workflows:', error)
return []
}
}
export const WorkflowBlock: BlockConfig = { export const WorkflowBlock: BlockConfig = {
type: 'workflow', type: 'workflow',
@@ -13,7 +38,8 @@ export const WorkflowBlock: BlockConfig = {
{ {
id: 'workflowId', id: 'workflowId',
title: 'Select Workflow', title: 'Select Workflow',
type: 'workflow-selector', type: 'combobox',
options: getAvailableWorkflows,
placeholder: 'Search workflows...', placeholder: 'Search workflows...',
required: true, required: true,
}, },

View File

@@ -1,5 +1,18 @@
import { WorkflowIcon } from '@/components/icons' import { WorkflowIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types' import type { BlockConfig } from '@/blocks/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const getAvailableWorkflows = (): Array<{ label: string; id: string }> => {
try {
const { workflows, activeWorkflowId } = useWorkflowRegistry.getState()
return Object.entries(workflows)
.filter(([id]) => id !== activeWorkflowId)
.map(([id, w]) => ({ label: w.name || `Workflow ${id.slice(0, 8)}`, id }))
.sort((a, b) => a.label.localeCompare(b.label))
} catch {
return []
}
}
export const WorkflowInputBlock: BlockConfig = { export const WorkflowInputBlock: BlockConfig = {
type: 'workflow_input', type: 'workflow_input',
@@ -12,19 +25,21 @@ export const WorkflowInputBlock: BlockConfig = {
`, `,
category: 'blocks', category: 'blocks',
docsLink: 'https://docs.sim.ai/blocks/workflow', docsLink: 'https://docs.sim.ai/blocks/workflow',
bgColor: '#6366F1', bgColor: '#6366F1', // Indigo - modern and professional
icon: WorkflowIcon, icon: WorkflowIcon,
subBlocks: [ subBlocks: [
{ {
id: 'workflowId', id: 'workflowId',
title: 'Select Workflow', title: 'Select Workflow',
type: 'workflow-selector', type: 'combobox',
options: getAvailableWorkflows,
placeholder: 'Search workflows...', placeholder: 'Search workflows...',
required: true, required: true,
}, },
// Renders dynamic mapping UI based on selected child workflow's Start trigger inputFormat
{ {
id: 'inputMapping', id: 'inputMapping',
title: 'Inputs', title: 'Input Mapping',
type: 'input-mapping', type: 'input-mapping',
description: description:
"Map fields defined in the child workflow's Start block to variables/values in this workflow.", "Map fields defined in the child workflow's Start block to variables/values in this workflow.",

View File

@@ -347,8 +347,8 @@ export class AgentBlockHandler implements BlockHandler {
): Promise<{ schema: any; code: string; title: string } | null> { ): Promise<{ schema: any; code: string; title: string } | null> {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
try { try {
const { getCustomTool } = await import('@/hooks/queries/custom-tools') const { useCustomToolsStore } = await import('@/stores/custom-tools')
const tool = getCustomTool(customToolId, ctx.workspaceId) const tool = useCustomToolsStore.getState().getTool(customToolId)
if (tool) { if (tool) {
return { return {
schema: tool.schema, schema: tool.schema,
@@ -356,9 +356,9 @@ export class AgentBlockHandler implements BlockHandler {
title: tool.title, title: tool.title,
} }
} }
logger.warn(`Custom tool not found in cache: ${customToolId}`) logger.warn(`Custom tool not found in store: ${customToolId}`)
} catch (error) { } catch (error) {
logger.error('Error accessing custom tools cache:', { error }) logger.error('Error accessing custom tools store:', { error })
} }
} }

View File

@@ -17,7 +17,6 @@ import {
} from '@/executor/human-in-the-loop/utils' } from '@/executor/human-in-the-loop/utils'
import type { BlockHandler, ExecutionContext, PauseMetadata } from '@/executor/types' import type { BlockHandler, ExecutionContext, PauseMetadata } from '@/executor/types'
import { collectBlockData } from '@/executor/utils/block-data' import { collectBlockData } from '@/executor/utils/block-data'
import { parseObjectStrings } from '@/executor/utils/json'
import type { SerializedBlock } from '@/serializer/types' import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools' import { executeTool } from '@/tools'
@@ -266,7 +265,7 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {
if (dataMode === 'structured' && inputs.builderData) { if (dataMode === 'structured' && inputs.builderData) {
const convertedData = this.convertBuilderDataToJson(inputs.builderData) const convertedData = this.convertBuilderDataToJson(inputs.builderData)
return parseObjectStrings(convertedData) return this.parseObjectStrings(convertedData)
} }
return inputs.data || {} return inputs.data || {}
@@ -486,6 +485,29 @@ 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 { private parseStatus(status?: string): number {
if (!status) return HTTP.STATUS.OK if (!status) return HTTP.STATUS.OK
const parsed = Number(status) const parsed = Number(status)

View File

@@ -1,7 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { BlockType, HTTP, REFERENCE } from '@/executor/constants' import { BlockType, HTTP, REFERENCE } from '@/executor/constants'
import type { BlockHandler, ExecutionContext, NormalizedBlockOutput } from '@/executor/types' import type { BlockHandler, ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
import { parseObjectStrings } from '@/executor/utils/json'
import type { SerializedBlock } from '@/serializer/types' import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('ResponseBlockHandler') const logger = createLogger('ResponseBlockHandler')
@@ -74,7 +73,7 @@ export class ResponseBlockHandler implements BlockHandler {
if (dataMode === 'structured' && inputs.builderData) { if (dataMode === 'structured' && inputs.builderData) {
const convertedData = this.convertBuilderDataToJson(inputs.builderData) const convertedData = this.convertBuilderDataToJson(inputs.builderData)
return parseObjectStrings(convertedData) return this.parseObjectStrings(convertedData)
} }
return inputs.data || {} return inputs.data || {}
@@ -223,6 +222,29 @@ 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 { private parseStatus(status?: string): number {
if (!status) return HTTP.STATUS.OK if (!status) return HTTP.STATUS.OK
const parsed = Number(status) const parsed = Number(status)

View File

@@ -40,30 +40,3 @@ export function isJSONString(value: string): boolean {
const trimmed = value.trim() const trimmed = value.trim()
return trimmed.startsWith('{') || trimmed.startsWith('[') 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<string, unknown> = {}
for (const [key, value] of Object.entries(data)) {
result[key] = parseObjectStrings(value)
}
return result
}
return data
}

View File

@@ -6,10 +6,6 @@ import type { ResolutionContext } from './reference'
vi.mock('@sim/logger', () => loggerMock) vi.mock('@sim/logger', () => loggerMock)
vi.mock('@/lib/workflows/blocks/block-outputs', () => ({
getBlockOutputs: vi.fn(() => ({})),
}))
function createTestWorkflow( function createTestWorkflow(
blocks: Array<{ blocks: Array<{
id: string id: string
@@ -144,21 +140,16 @@ describe('BlockResolver', () => {
expect(resolver.resolve('<source.nonexistent>', ctx)).toBeUndefined() expect(resolver.resolve('<source.nonexistent>', ctx)).toBeUndefined()
}) })
it.concurrent('should throw error for path not in output schema', async () => { it.concurrent('should throw error for path not in output schema', () => {
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([ const workflow = createTestWorkflow([
{ {
id: 'source', id: 'source',
outputs: customOutputs, outputs: {
validField: { type: 'string', description: 'A valid field' },
nested: {
child: { type: 'number', description: 'Nested child' },
},
},
}, },
]) ])
const resolver = new BlockResolver(workflow) const resolver = new BlockResolver(workflow)
@@ -170,8 +161,6 @@ describe('BlockResolver', () => {
/"invalidField" doesn't exist on block "source"/ /"invalidField" doesn't exist on block "source"/
) )
expect(() => resolver.resolve('<source.invalidField>', ctx)).toThrow(/Available fields:/) expect(() => resolver.resolve('<source.invalidField>', ctx)).toThrow(/Available fields:/)
mockGetBlockOutputs.mockReturnValue({})
}) })
it.concurrent('should return undefined for path in schema but missing in data', () => { it.concurrent('should return undefined for path in schema but missing in data', () => {
@@ -309,6 +298,45 @@ 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', () => { describe('Response block backwards compatibility', () => {
it.concurrent('should resolve new format: <responseBlock.data>', () => { it.concurrent('should resolve new format: <responseBlock.data>', () => {
const workflow = createTestWorkflow([ const workflow = createTestWorkflow([

View File

@@ -1,4 +1,3 @@
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { USER_FILE_ACCESSIBLE_PROPERTIES } from '@/lib/workflows/types' import { USER_FILE_ACCESSIBLE_PROPERTIES } from '@/lib/workflows/types'
import { import {
isReference, isReference,
@@ -230,15 +229,9 @@ export class BlockResolver implements Resolver {
} }
} }
const blockType = block?.metadata?.id
const params = block?.config?.params as Record<string, unknown> | undefined
const subBlocks = params
? Object.fromEntries(Object.entries(params).map(([k, v]) => [k, { value: v }]))
: undefined
const toolId = block?.config?.tool const toolId = block?.config?.tool
const toolConfig = toolId ? getTool(toolId) : undefined const toolConfig = toolId ? getTool(toolId) : undefined
const outputSchema = const outputSchema = toolConfig?.outputs ?? block?.outputs
toolConfig?.outputs ?? (blockType ? getBlockOutputs(blockType, subBlocks) : block?.outputs)
const schemaFields = getSchemaFieldNames(outputSchema) const schemaFields = getSchemaFieldNames(outputSchema)
if (schemaFields.length > 0 && !isPathInOutputSchema(outputSchema, pathParts)) { if (schemaFields.length > 0 && !isPathInOutputSchema(outputSchema, pathParts)) {
throw new Error( throw new Error(
@@ -343,4 +336,21 @@ export class BlockResolver implements Resolver {
} }
return String(value) 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
}
} }

View File

@@ -1,34 +1,11 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getQueryClient } from '@/app/_shell/providers/query-provider' import type { CustomToolDefinition, CustomToolSchema } from '@/stores/custom-tools'
import { useCustomToolsStore } from '@/stores/custom-tools'
const logger = createLogger('CustomToolsQueries') const logger = createLogger('CustomToolsQueries')
const API_ENDPOINT = '/api/tools/custom' const API_ENDPOINT = '/api/tools/custom'
export interface CustomToolSchema {
type: string
function: {
name: string
description?: string
parameters: {
type: string
properties: Record<string, unknown>
required?: string[]
}
}
}
export interface CustomToolDefinition {
id: string
workspaceId: string | null
userId: string | null
title: string
schema: CustomToolSchema
code: string
createdAt: string
updatedAt?: string
}
/** /**
* Query key factories for custom tools queries * Query key factories for custom tools queries
*/ */
@@ -87,39 +64,8 @@ function normalizeCustomTool(tool: ApiCustomTool, workspaceId: string): CustomTo
} }
} }
/** function syncCustomToolsToStore(tools: CustomToolDefinition[]) {
* Extract workspaceId from the current URL path useCustomToolsStore.getState().setTools(tools)
* Expected format: /workspace/{workspaceId}/...
*/
function getWorkspaceIdFromUrl(): string | null {
if (typeof window === 'undefined') return null
const match = window.location.pathname.match(/^\/workspace\/([^/]+)/)
return match?.[1] ?? null
}
/**
* Get all custom tools from the query cache (for non-React code)
* If workspaceId is not provided, extracts it from the current URL
*/
export function getCustomTools(workspaceId?: string): CustomToolDefinition[] {
if (typeof window === 'undefined') return []
const wsId = workspaceId ?? getWorkspaceIdFromUrl()
if (!wsId) return []
const queryClient = getQueryClient()
return queryClient.getQueryData<CustomToolDefinition[]>(customToolsKeys.list(wsId)) ?? []
}
/**
* Get a specific custom tool from the query cache by ID or title (for non-React code)
* Custom tools are referenced by title in the system (custom_${title}), so title lookup is required.
* If workspaceId is not provided, extracts it from the current URL
*/
export function getCustomTool(
identifier: string,
workspaceId?: string
): CustomToolDefinition | undefined {
const tools = getCustomTools(workspaceId)
return tools.find((tool) => tool.id === identifier || tool.title === identifier)
} }
/** /**
@@ -188,13 +134,19 @@ async function fetchCustomTools(workspaceId: string): Promise<CustomToolDefiniti
* Hook to fetch custom tools * Hook to fetch custom tools
*/ */
export function useCustomTools(workspaceId: string) { export function useCustomTools(workspaceId: string) {
return useQuery<CustomToolDefinition[]>({ const query = useQuery<CustomToolDefinition[]>({
queryKey: customToolsKeys.list(workspaceId), queryKey: customToolsKeys.list(workspaceId),
queryFn: () => fetchCustomTools(workspaceId), queryFn: () => fetchCustomTools(workspaceId),
enabled: !!workspaceId, enabled: !!workspaceId,
staleTime: 60 * 1000, // 1 minute - tools don't change frequently staleTime: 60 * 1000, // 1 minute - tools don't change frequently
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}) })
if (query.data) {
syncCustomToolsToStore(query.data)
}
return query
} }
/** /**

View File

@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getNextWorkflowColor } from '@/lib/workflows/colors' import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { extractInputFieldsFromBlocks, type WorkflowInputField } from '@/lib/workflows/input-format'
import { deploymentKeys } from '@/hooks/queries/deployments' import { deploymentKeys } from '@/hooks/queries/deployments'
import { import {
createOptimisticMutationHandlers, createOptimisticMutationHandlers,
@@ -25,35 +26,34 @@ export const workflowKeys = {
deploymentVersions: () => [...workflowKeys.all, 'deploymentVersion'] as const, deploymentVersions: () => [...workflowKeys.all, 'deploymentVersion'] as const,
deploymentVersion: (workflowId: string | undefined, version: number | undefined) => deploymentVersion: (workflowId: string | undefined, version: number | undefined) =>
[...workflowKeys.deploymentVersions(), workflowId ?? '', version ?? 0] as const, [...workflowKeys.deploymentVersions(), workflowId ?? '', version ?? 0] as const,
state: (workflowId: string | undefined) => inputFields: (workflowId: string | undefined) =>
[...workflowKeys.all, 'state', workflowId ?? ''] as const, [...workflowKeys.all, 'inputFields', workflowId ?? ''] as const,
} }
/** /**
* Fetches workflow state from the API. * Fetches workflow input fields from the workflow state.
* Used as the base query for both state preview and input fields extraction.
*/ */
async function fetchWorkflowState(workflowId: string): Promise<WorkflowState | null> { async function fetchWorkflowInputFields(workflowId: string): Promise<WorkflowInputField[]> {
const response = await fetch(`/api/workflows/${workflowId}`) const response = await fetch(`/api/workflows/${workflowId}`)
if (!response.ok) throw new Error('Failed to fetch workflow') if (!response.ok) throw new Error('Failed to fetch workflow')
const { data } = await response.json() const { data } = await response.json()
return data?.state ?? null return extractInputFieldsFromBlocks(data?.state?.blocks)
} }
/** /**
* Hook to fetch workflow state. * Hook to fetch workflow input fields for configuration.
* Used by workflow blocks to show a preview of the child workflow * Uses React Query for caching and deduplication.
* and as a base query for input fields extraction.
* *
* @param workflowId - The workflow ID to fetch state for * @param workflowId - The workflow ID to fetch input fields for
* @returns Query result with workflow state * @returns Query result with input fields array
*/ */
export function useWorkflowState(workflowId: string | undefined) { export function useWorkflowInputFields(workflowId: string | undefined) {
return useQuery({ return useQuery({
queryKey: workflowKeys.state(workflowId), queryKey: workflowKeys.inputFields(workflowId),
queryFn: () => fetchWorkflowState(workflowId!), queryFn: () => fetchWorkflowInputFields(workflowId!),
enabled: Boolean(workflowId), enabled: Boolean(workflowId),
staleTime: 30 * 1000, // 30 seconds staleTime: 0,
refetchOnMount: 'always',
}) })
} }
@@ -532,19 +532,13 @@ export interface ChildDeploymentStatus {
} }
/** /**
* Fetches deployment status for a child workflow. * Fetches deployment status for a child workflow
* Uses Promise.all to fetch status and deployments in parallel for better performance.
*/ */
async function fetchChildDeploymentStatus(workflowId: string): Promise<ChildDeploymentStatus> { async function fetchChildDeploymentStatus(workflowId: string): Promise<ChildDeploymentStatus> {
const fetchOptions = { const statusRes = await fetch(`/api/workflows/${workflowId}/status`, {
cache: 'no-store' as const, cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' }, headers: { 'Cache-Control': 'no-cache' },
} })
const [statusRes, deploymentsRes] = await Promise.all([
fetch(`/api/workflows/${workflowId}/status`, fetchOptions),
fetch(`/api/workflows/${workflowId}/deployments`, fetchOptions),
])
if (!statusRes.ok) { if (!statusRes.ok) {
throw new Error('Failed to fetch workflow status') throw new Error('Failed to fetch workflow status')
@@ -552,6 +546,11 @@ async function fetchChildDeploymentStatus(workflowId: string): Promise<ChildDepl
const statusData = await statusRes.json() const statusData = await statusRes.json()
const deploymentsRes = await fetch(`/api/workflows/${workflowId}/deployments`, {
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' },
})
let activeVersion: number | null = null let activeVersion: number | null = null
if (deploymentsRes.ok) { if (deploymentsRes.ok) {
const deploymentsJson = await deploymentsRes.json() const deploymentsJson = await deploymentsRes.json()
@@ -581,7 +580,7 @@ export function useChildDeploymentStatus(workflowId: string | undefined) {
queryKey: workflowKeys.deploymentStatus(workflowId), queryKey: workflowKeys.deploymentStatus(workflowId),
queryFn: () => fetchChildDeploymentStatus(workflowId!), queryFn: () => fetchChildDeploymentStatus(workflowId!),
enabled: Boolean(workflowId), enabled: Boolean(workflowId),
staleTime: 0, staleTime: 30 * 1000, // 30 seconds
retry: false, retry: false,
}) })
} }

View File

@@ -6,7 +6,6 @@ import type {
SelectorOption, SelectorOption,
SelectorQueryArgs, SelectorQueryArgs,
} from '@/hooks/selectors/types' } from '@/hooks/selectors/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const SELECTOR_STALE = 60 * 1000 const SELECTOR_STALE = 60 * 1000
@@ -854,36 +853,6 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
})) }))
}, },
}, },
'sim.workflows': {
key: 'sim.workflows',
staleTime: 0, // Always fetch fresh from store
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'sim.workflows',
context.excludeWorkflowId ?? 'none',
],
enabled: () => true,
fetchList: async ({ context }: SelectorQueryArgs): Promise<SelectorOption[]> => {
const { workflows } = useWorkflowRegistry.getState()
return Object.entries(workflows)
.filter(([id]) => id !== context.excludeWorkflowId)
.map(([id, workflow]) => ({
id,
label: workflow.name || `Workflow ${id.slice(0, 8)}`,
}))
.sort((a, b) => a.label.localeCompare(b.label))
},
fetchById: async ({ detailId }: SelectorQueryArgs): Promise<SelectorOption | null> => {
if (!detailId) return null
const { workflows } = useWorkflowRegistry.getState()
const workflow = workflows[detailId]
if (!workflow) return null
return {
id: detailId,
label: workflow.name || `Workflow ${detailId.slice(0, 8)}`,
}
},
},
} }
export function getSelectorDefinition(key: SelectorKey): SelectorDefinition { export function getSelectorDefinition(key: SelectorKey): SelectorDefinition {

View File

@@ -29,7 +29,6 @@ export type SelectorKey =
| 'webflow.sites' | 'webflow.sites'
| 'webflow.collections' | 'webflow.collections'
| 'webflow.items' | 'webflow.items'
| 'sim.workflows'
export interface SelectorOption { export interface SelectorOption {
id: string id: string
@@ -53,7 +52,6 @@ export interface SelectorContext {
siteId?: string siteId?: string
collectionId?: string collectionId?: string
spreadsheetId?: string spreadsheetId?: string
excludeWorkflowId?: string
} }
export interface SelectorQueryArgs { export interface SelectorQueryArgs {

View File

@@ -5,7 +5,6 @@ import { useShallow } from 'zustand/react/shallow'
import { useSession } from '@/lib/auth/auth-client' import { useSession } from '@/lib/auth/auth-client'
import { useSocket } from '@/app/workspace/providers/socket-provider' import { useSocket } from '@/app/workspace/providers/socket-provider'
import { getBlock } from '@/blocks' import { getBlock } from '@/blocks'
import { normalizeName } from '@/executor/constants'
import { useUndoRedo } from '@/hooks/use-undo-redo' import { useUndoRedo } from '@/hooks/use-undo-redo'
import { import {
BLOCK_OPERATIONS, BLOCK_OPERATIONS,
@@ -24,7 +23,7 @@ import { useUndoRedoStore } from '@/stores/undo-redo'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { filterNewEdges, mergeSubblockState } from '@/stores/workflows/utils' import { filterNewEdges, mergeSubblockState, normalizeName } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState, Loop, Parallel, Position } from '@/stores/workflows/workflow/types' import type { BlockState, Loop, Parallel, Position } from '@/stores/workflows/workflow/types'
@@ -113,6 +112,9 @@ export function useCollaborativeWorkflow() {
const { const {
isConnected, isConnected,
currentWorkflowId, currentWorkflowId,
presenceUsers,
joinWorkflow,
leaveWorkflow,
emitWorkflowOperation, emitWorkflowOperation,
emitSubblockUpdate, emitSubblockUpdate,
emitVariableUpdate, emitVariableUpdate,
@@ -140,7 +142,13 @@ export function useCollaborativeWorkflow() {
// Track if we're applying remote changes to avoid infinite loops // Track if we're applying remote changes to avoid infinite loops
const isApplyingRemoteChange = useRef(false) const isApplyingRemoteChange = useRef(false)
// Track last applied position timestamps to prevent out-of-order updates
const lastPositionTimestamps = useRef<Map<string, number>>(new Map())
// Operation queue
const { const {
queue,
hasOperationError,
addToQueue, addToQueue,
confirmOperation, confirmOperation,
failOperation, failOperation,
@@ -152,6 +160,22 @@ export function useCollaborativeWorkflow() {
return !!currentWorkflowId && activeWorkflowId === currentWorkflowId return !!currentWorkflowId && activeWorkflowId === currentWorkflowId
}, [currentWorkflowId, activeWorkflowId]) }, [currentWorkflowId, activeWorkflowId])
// Clear position timestamps when switching workflows
// Note: Workflow joining is now handled automatically by socket connect event based on URL
useEffect(() => {
if (activeWorkflowId && currentWorkflowId !== activeWorkflowId) {
logger.info(`Active workflow changed to: ${activeWorkflowId}`, {
isConnected,
currentWorkflowId,
activeWorkflowId,
presenceUsers: presenceUsers.length,
})
// Clear position timestamps when switching workflows
lastPositionTimestamps.current.clear()
}
}, [activeWorkflowId, isConnected, currentWorkflowId])
// Register emit functions with operation queue store // Register emit functions with operation queue store
useEffect(() => { useEffect(() => {
registerEmitFunctions( registerEmitFunctions(
@@ -1596,8 +1620,15 @@ export function useCollaborativeWorkflow() {
) )
return { return {
// Connection status
isConnected, isConnected,
currentWorkflowId, currentWorkflowId,
presenceUsers,
hasOperationError,
// Workflow management
joinWorkflow,
leaveWorkflow,
// Collaborative operations // Collaborative operations
collaborativeBatchUpdatePositions, collaborativeBatchUpdatePositions,

View File

@@ -64,8 +64,6 @@ import { SSO_TRUSTED_PROVIDERS } from './sso/constants'
const logger = createLogger('Auth') const logger = createLogger('Auth')
import { getMicrosoftRefreshTokenExpiry, isMicrosoftProvider } from '@/lib/oauth/microsoft'
const validStripeKey = env.STRIPE_SECRET_KEY const validStripeKey = env.STRIPE_SECRET_KEY
let stripeClient = null let stripeClient = null
@@ -189,10 +187,6 @@ export const auth = betterAuth({
} }
} }
const refreshTokenExpiresAt = isMicrosoftProvider(account.providerId)
? getMicrosoftRefreshTokenExpiry()
: account.refreshTokenExpiresAt
await db await db
.update(schema.account) .update(schema.account)
.set({ .set({
@@ -201,7 +195,7 @@ export const auth = betterAuth({
refreshToken: account.refreshToken, refreshToken: account.refreshToken,
idToken: account.idToken, idToken: account.idToken,
accessTokenExpiresAt: account.accessTokenExpiresAt, accessTokenExpiresAt: account.accessTokenExpiresAt,
refreshTokenExpiresAt, refreshTokenExpiresAt: account.refreshTokenExpiresAt,
scope: scopeToStore, scope: scopeToStore,
updatedAt: new Date(), updatedAt: new Date(),
}) })
@@ -298,13 +292,6 @@ export const auth = betterAuth({
} }
} }
if (isMicrosoftProvider(account.providerId)) {
await db
.update(schema.account)
.set({ refreshTokenExpiresAt: getMicrosoftRefreshTokenExpiry() })
.where(eq(schema.account.id, account.id))
}
// Sync webhooks for credential sets after connecting a new credential // Sync webhooks for credential sets after connecting a new credential
const requestId = crypto.randomUUID().slice(0, 8) const requestId = crypto.randomUUID().slice(0, 8)
const userMemberships = await db const userMemberships = await db
@@ -400,6 +387,7 @@ export const auth = betterAuth({
enabled: true, enabled: true,
allowDifferentEmails: true, allowDifferentEmails: true,
trustedProviders: [ trustedProviders: [
// Standard OAuth providers
'google', 'google',
'github', 'github',
'email-password', 'email-password',
@@ -415,32 +403,8 @@ export const auth = betterAuth({
'hubspot', 'hubspot',
'linkedin', 'linkedin',
'spotify', 'spotify',
'google-email',
'google-calendar', // Common SSO provider patterns
'google-drive',
'google-docs',
'google-sheets',
'google-forms',
'google-vault',
'google-groups',
'vertex-ai',
'github-repo',
'microsoft-teams',
'microsoft-excel',
'microsoft-planner',
'outlook',
'onedrive',
'sharepoint',
'jira',
'airtable',
'dropbox',
'salesforce',
'wealthbox',
'zoom',
'wordpress',
'linear',
'shopify',
'trello',
...SSO_TRUSTED_PROVIDERS, ...SSO_TRUSTED_PROVIDERS,
], ],
}, },

View File

@@ -6,7 +6,7 @@ import {
type BaseClientToolMetadata, type BaseClientToolMetadata,
ClientToolCallState, ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool' } from '@/lib/copilot/tools/client/base-tool'
import { getCustomTool } from '@/hooks/queries/custom-tools' import { useCustomToolsStore } from '@/stores/custom-tools'
import { useCopilotStore } from '@/stores/panel/copilot/store' import { useCopilotStore } from '@/stores/panel/copilot/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -83,15 +83,17 @@ export class ManageCustomToolClientTool extends BaseClientTool {
getDynamicText: (params, state) => { getDynamicText: (params, state) => {
const operation = params?.operation as 'add' | 'edit' | 'delete' | 'list' | undefined const operation = params?.operation as 'add' | 'edit' | 'delete' | 'list' | undefined
// Return undefined if no operation yet - use static defaults
if (!operation) return undefined if (!operation) return undefined
// Get tool name from schema, or look it up from the store by toolId
let toolName = params?.schema?.function?.name let toolName = params?.schema?.function?.name
if (!toolName && params?.toolId) { if (!toolName && params?.toolId) {
try { try {
const tool = getCustomTool(params.toolId) const tool = useCustomToolsStore.getState().getTool(params.toolId)
toolName = tool?.schema?.function?.name toolName = tool?.schema?.function?.name
} catch { } catch {
// Ignore errors accessing cache // Ignore errors accessing store
} }
} }
@@ -166,6 +168,7 @@ export class ManageCustomToolClientTool extends BaseClientTool {
* Add operations execute directly without confirmation. * Add operations execute directly without confirmation.
*/ */
getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined { getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
// Try currentArgs first, then fall back to store (for when called before execute())
const args = this.currentArgs || this.getArgsFromStore() const args = this.currentArgs || this.getArgsFromStore()
const operation = args?.operation const operation = args?.operation
if (operation === 'edit' || operation === 'delete') { if (operation === 'edit' || operation === 'delete') {
@@ -196,9 +199,12 @@ export class ManageCustomToolClientTool extends BaseClientTool {
async execute(args?: ManageCustomToolArgs): Promise<void> { async execute(args?: ManageCustomToolArgs): Promise<void> {
this.currentArgs = args this.currentArgs = args
// For add and list operations, execute directly without confirmation
// For edit/delete, the copilot store will check hasInterrupt() and wait for confirmation
if (args?.operation === 'add' || args?.operation === 'list') { if (args?.operation === 'add' || args?.operation === 'list') {
await this.handleAccept(args) await this.handleAccept(args)
} }
// edit/delete will wait for user confirmation via handleAccept
} }
/** /**
@@ -216,6 +222,7 @@ export class ManageCustomToolClientTool extends BaseClientTool {
const { operation, toolId, schema, code } = args const { operation, toolId, schema, code } = args
// Get workspace ID from the workflow registry
const { hydration } = useWorkflowRegistry.getState() const { hydration } = useWorkflowRegistry.getState()
const workspaceId = hydration.workspaceId const workspaceId = hydration.workspaceId
if (!workspaceId) { if (!workspaceId) {
@@ -240,6 +247,7 @@ export class ManageCustomToolClientTool extends BaseClientTool {
await this.deleteCustomTool({ toolId, workspaceId }, logger) await this.deleteCustomTool({ toolId, workspaceId }, logger)
break break
case 'list': case 'list':
// List operation is read-only, just mark as complete
await this.markToolComplete(200, 'Listed custom tools') await this.markToolComplete(200, 'Listed custom tools')
break break
default: default:
@@ -318,10 +326,13 @@ export class ManageCustomToolClientTool extends BaseClientTool {
throw new Error('Tool ID is required for editing a custom tool') throw new Error('Tool ID is required for editing a custom tool')
} }
// At least one of schema or code must be provided
if (!schema && !code) { if (!schema && !code) {
throw new Error('At least one of schema or code must be provided for editing') throw new Error('At least one of schema or code must be provided for editing')
} }
// We need to send the full tool data to the API for updates
// First, fetch the existing tool to merge with updates
const existingResponse = await fetch(`${API_ENDPOINT}?workspaceId=${workspaceId}`) const existingResponse = await fetch(`${API_ENDPOINT}?workspaceId=${workspaceId}`)
const existingData = await existingResponse.json() const existingData = await existingResponse.json()
@@ -334,6 +345,7 @@ export class ManageCustomToolClientTool extends BaseClientTool {
throw new Error(`Tool with ID ${toolId} not found`) throw new Error(`Tool with ID ${toolId} not found`)
} }
// Merge updates with existing tool - use function name as title
const mergedSchema = schema ?? existingTool.schema const mergedSchema = schema ?? existingTool.schema
const updatedTool = { const updatedTool = {
id: toolId, id: toolId,

View File

@@ -123,8 +123,6 @@ function resolveSubBlockOptions(sb: SubBlockConfig): string[] | undefined {
interface OutputFieldSchema { interface OutputFieldSchema {
type: string type: string
description?: string description?: string
properties?: Record<string, OutputFieldSchema>
items?: { type: string }
} }
/** /**
@@ -258,42 +256,6 @@ function mapSubBlockTypeToSchemaType(type: string): string {
return typeMap[type] || 'string' return typeMap[type] || 'string'
} }
/**
* Extracts a single output field schema, including nested properties
*/
function extractOutputField(def: any): OutputFieldSchema {
if (typeof def === 'string') {
return { type: def }
}
if (typeof def !== 'object' || def === null) {
return { type: 'any' }
}
const field: OutputFieldSchema = {
type: def.type || 'any',
}
if (def.description) {
field.description = def.description
}
// Include nested properties if present
if (def.properties && typeof def.properties === 'object') {
field.properties = {}
for (const [propKey, propDef] of Object.entries(def.properties)) {
field.properties[propKey] = extractOutputField(propDef)
}
}
// Include items schema for arrays
if (def.items && typeof def.items === 'object') {
field.items = { type: def.items.type || 'any' }
}
return field
}
/** /**
* Extracts trigger outputs from the first available trigger * Extracts trigger outputs from the first available trigger
*/ */
@@ -310,7 +272,15 @@ function extractTriggerOutputs(blockConfig: any): Record<string, OutputFieldSche
const trigger = getTrigger(triggerId) const trigger = getTrigger(triggerId)
if (trigger.outputs) { if (trigger.outputs) {
for (const [key, def] of Object.entries(trigger.outputs)) { for (const [key, def] of Object.entries(trigger.outputs)) {
outputs[key] = extractOutputField(def) if (typeof def === 'string') {
outputs[key] = { type: def }
} else if (typeof def === 'object' && def !== null) {
const typedDef = def as { type?: string; description?: string }
outputs[key] = {
type: typedDef.type || 'any',
description: typedDef.description,
}
}
} }
} }
} }
@@ -342,7 +312,11 @@ function extractOutputs(
const tool = toolsRegistry[toolId] const tool = toolsRegistry[toolId]
if (tool?.outputs) { if (tool?.outputs) {
for (const [key, def] of Object.entries(tool.outputs)) { for (const [key, def] of Object.entries(tool.outputs)) {
outputs[key] = extractOutputField(def) const typedDef = def as { type: string; description?: string }
outputs[key] = {
type: typedDef.type || 'any',
description: typedDef.description,
}
} }
return outputs return outputs
} }
@@ -355,7 +329,15 @@ function extractOutputs(
// Use block-level outputs // Use block-level outputs
if (blockConfig.outputs) { if (blockConfig.outputs) {
for (const [key, def] of Object.entries(blockConfig.outputs)) { for (const [key, def] of Object.entries(blockConfig.outputs)) {
outputs[key] = extractOutputField(def) if (typeof def === 'string') {
outputs[key] = { type: def }
} else if (typeof def === 'object' && def !== null) {
const typedDef = def as { type?: string; description?: string }
outputs[key] = {
type: typedDef.type || 'any',
description: typedDef.description,
}
}
} }
} }

View File

@@ -2468,17 +2468,16 @@ async function validateWorkflowSelectorIds(
const result = await validateSelectorIds(selector.selectorType, selector.value, context) const result = await validateSelectorIds(selector.selectorType, selector.value, context)
if (result.invalid.length > 0) { if (result.invalid.length > 0) {
// Include warning info (like available credentials) in the error message for better LLM feedback
const warningInfo = result.warning ? `. ${result.warning}` : ''
errors.push({ errors.push({
blockId: selector.blockId, blockId: selector.blockId,
blockType: selector.blockType, blockType: selector.blockType,
field: selector.fieldName, field: selector.fieldName,
value: selector.value, value: selector.value,
error: `Invalid ${selector.selectorType} ID(s): ${result.invalid.join(', ')} - ID(s) do not exist or user doesn't have access${warningInfo}`, error: `Invalid ${selector.selectorType} ID(s): ${result.invalid.join(', ')} - ID(s) do not exist`,
}) })
} else if (result.warning) { }
// Log warnings that don't have errors (shouldn't happen for credentials but may for other selectors)
if (result.warning) {
logger.warn(result.warning, { logger.warn(result.warning, {
blockId: selector.blockId, blockId: selector.blockId,
fieldName: selector.fieldName, fieldName: selector.fieldName,

View File

@@ -39,31 +39,6 @@ export async function validateSelectorIds(
.from(account) .from(account)
.where(and(inArray(account.id, idsArray), eq(account.userId, context.userId))) .where(and(inArray(account.id, idsArray), eq(account.userId, context.userId)))
existingIds = results.map((r) => r.id) existingIds = results.map((r) => r.id)
// If any IDs are invalid, fetch user's available credentials to include in error message
const existingSet = new Set(existingIds)
const invalidIds = idsArray.filter((id) => !existingSet.has(id))
if (invalidIds.length > 0) {
// Fetch all of the user's credentials to provide helpful feedback
const allUserCredentials = await db
.select({ id: account.id, providerId: account.providerId })
.from(account)
.where(eq(account.userId, context.userId))
const availableCredentials = allUserCredentials
.map((c) => `${c.id} (${c.providerId})`)
.join(', ')
const noCredentialsMessage = 'User has no credentials configured.'
return {
valid: existingIds,
invalid: invalidIds,
warning:
allUserCredentials.length > 0
? `Available credentials for this user: ${availableCredentials}`
: noCredentialsMessage,
}
}
break break
} }

View File

@@ -1,4 +1,3 @@
export * from './microsoft'
export * from './oauth' export * from './oauth'
export * from './types' export * from './types'
export * from './utils' export * from './utils'

View File

@@ -1,19 +0,0 @@
export const MICROSOFT_REFRESH_TOKEN_LIFETIME_DAYS = 90
export const PROACTIVE_REFRESH_THRESHOLD_DAYS = 7
export const MICROSOFT_PROVIDERS = new Set([
'microsoft-excel',
'microsoft-planner',
'microsoft-teams',
'outlook',
'onedrive',
'sharepoint',
])
export function isMicrosoftProvider(providerId: string): boolean {
return MICROSOFT_PROVIDERS.has(providerId)
}
export function getMicrosoftRefreshTokenExpiry(): Date {
return new Date(Date.now() + MICROSOFT_REFRESH_TOKEN_LIFETIME_DAYS * 24 * 60 * 60 * 1000)
}

View File

@@ -835,7 +835,6 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
clientId, clientId,
clientSecret, clientSecret,
useBasicAuth: true, useBasicAuth: true,
supportsRefreshTokenRotation: true,
} }
} }
case 'confluence': { case 'confluence': {
@@ -884,7 +883,6 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
clientId, clientId,
clientSecret, clientSecret,
useBasicAuth: false, useBasicAuth: false,
supportsRefreshTokenRotation: true,
} }
} }
case 'microsoft': case 'microsoft':
@@ -912,7 +910,6 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
clientId, clientId,
clientSecret, clientSecret,
useBasicAuth: true, useBasicAuth: true,
supportsRefreshTokenRotation: true,
} }
} }
case 'dropbox': { case 'dropbox': {

View File

@@ -1,8 +1,4 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import {
extractFieldsFromSchema,
parseResponseFormatSafely,
} from '@/lib/core/utils/response-format'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format' import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { import {
classifyStartBlockType, classifyStartBlockType,
@@ -309,26 +305,6 @@ export function getBlockOutputs(
return getLegacyStarterOutputs(subBlocks) 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 baseOutputs = { ...(blockConfig.outputs || {}) }
const filteredOutputs = filterOutputsByCondition(baseOutputs, subBlocks) const filteredOutputs = filterOutputsByCondition(baseOutputs, subBlocks)
return applyInputFormatToOutputs(blockType, blockConfig, subBlocks, filteredOutputs) return applyInputFormatToOutputs(blockType, blockConfig, subBlocks, filteredOutputs)

View File

@@ -11,6 +11,7 @@ import {
getAllProviderIds, getAllProviderIds,
getApiKey, getApiKey,
getBaseModelProviders, getBaseModelProviders,
getCustomTools,
getHostedModels, getHostedModels,
getMaxTemperature, getMaxTemperature,
getProvider, getProvider,
@@ -29,6 +30,7 @@ import {
shouldBillModelUsage, shouldBillModelUsage,
supportsTemperature, supportsTemperature,
supportsToolUsageControl, supportsToolUsageControl,
transformCustomTool,
updateOllamaProviderModels, updateOllamaProviderModels,
} from '@/providers/utils' } from '@/providers/utils'
@@ -835,6 +837,51 @@ describe('JSON and Structured Output', () => {
}) })
describe('Tool Management', () => { describe('Tool Management', () => {
describe('transformCustomTool', () => {
it.concurrent('should transform valid custom tool schema', () => {
const customTool = {
id: 'test-tool',
schema: {
function: {
name: 'testFunction',
description: 'A test function',
parameters: {
type: 'object',
properties: {
input: { type: 'string', description: 'Input parameter' },
},
required: ['input'],
},
},
},
}
const result = transformCustomTool(customTool)
expect(result.id).toBe('custom_test-tool')
expect(result.name).toBe('testFunction')
expect(result.description).toBe('A test function')
expect(result.parameters.type).toBe('object')
expect(result.parameters.properties).toBeDefined()
expect(result.parameters.required).toEqual(['input'])
})
it.concurrent('should throw error for invalid schema', () => {
const invalidTool = { id: 'test', schema: null }
expect(() => transformCustomTool(invalidTool)).toThrow('Invalid custom tool schema')
const noFunction = { id: 'test', schema: {} }
expect(() => transformCustomTool(noFunction)).toThrow('Invalid custom tool schema')
})
})
describe('getCustomTools', () => {
it.concurrent('should return array of transformed custom tools', () => {
const result = getCustomTools()
expect(Array.isArray(result)).toBe(true)
})
})
describe('prepareToolsWithUsageControl', () => { describe('prepareToolsWithUsageControl', () => {
const mockLogger = { const mockLogger = {
info: vi.fn(), info: vi.fn(),

View File

@@ -28,6 +28,7 @@ import {
updateOllamaModels as updateOllamaModelsInDefinitions, updateOllamaModels as updateOllamaModelsInDefinitions,
} from '@/providers/models' } from '@/providers/models'
import type { ProviderId, ProviderToolConfig } from '@/providers/types' import type { ProviderId, ProviderToolConfig } from '@/providers/types'
import { useCustomToolsStore } from '@/stores/custom-tools/store'
import { useProvidersStore } from '@/stores/providers/store' import { useProvidersStore } from '@/stores/providers/store'
import { mergeToolParameters } from '@/tools/params' import { mergeToolParameters } from '@/tools/params'
@@ -418,6 +419,38 @@ export function extractAndParseJSON(content: string): any {
} }
} }
/**
* Transforms a custom tool schema into a provider tool config
*/
export function transformCustomTool(customTool: any): ProviderToolConfig {
const schema = customTool.schema
if (!schema || !schema.function) {
throw new Error('Invalid custom tool schema')
}
return {
id: `custom_${customTool.id}`,
name: schema.function.name,
description: schema.function.description || '',
params: {},
parameters: {
type: schema.function.parameters.type,
properties: schema.function.parameters.properties,
required: schema.function.parameters.required || [],
},
}
}
/**
* Gets all available custom tools as provider tool configs
*/
export function getCustomTools(): ProviderToolConfig[] {
const customTools = useCustomToolsStore.getState().getAllTools()
return customTools.map(transformCustomTool)
}
/** /**
* Transforms a block tool into a provider tool config with operation selection * Transforms a block tool into a provider tool config with operation selection
* *

View File

@@ -0,0 +1,8 @@
export { useCustomToolsStore } from './store'
export type {
CustomToolDefinition,
CustomToolSchema,
CustomToolsActions,
CustomToolsState,
CustomToolsStore,
} from './types'

View File

@@ -0,0 +1,36 @@
import { createLogger } from '@sim/logger'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import type { CustomToolsState, CustomToolsStore } from './types'
const logger = createLogger('CustomToolsStore')
const initialState: CustomToolsState = {
tools: [],
}
export const useCustomToolsStore = create<CustomToolsStore>()(
devtools(
(set, get) => ({
...initialState,
setTools: (tools) => {
logger.info(`Synced ${tools.length} custom tools`)
set({ tools })
},
getTool: (id: string) => {
return get().tools.find((tool) => tool.id === id)
},
getAllTools: () => {
return get().tools
},
reset: () => set(initialState),
}),
{
name: 'custom-tools-store',
}
)
)

View File

@@ -0,0 +1,36 @@
export interface CustomToolSchema {
type: string
function: {
name: string
description?: string
parameters: {
type: string
properties: Record<string, any>
required?: string[]
}
}
}
export interface CustomToolDefinition {
id: string
workspaceId: string | null
userId: string | null
title: string
schema: CustomToolSchema
code: string
createdAt: string
updatedAt?: string
}
export interface CustomToolsState {
tools: CustomToolDefinition[]
}
export interface CustomToolsActions {
setTools: (tools: CustomToolDefinition[]) => void
getTool: (id: string) => CustomToolDefinition | undefined
getAllTools: () => CustomToolDefinition[]
reset: () => void
}
export interface CustomToolsStore extends CustomToolsState, CustomToolsActions {}

View File

@@ -2,6 +2,7 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { useCustomToolsStore } from '@/stores/custom-tools'
import { useExecutionStore } from '@/stores/execution' import { useExecutionStore } from '@/stores/execution'
import { useCopilotStore, useVariablesStore } from '@/stores/panel' import { useCopilotStore, useVariablesStore } from '@/stores/panel'
import { useEnvironmentStore } from '@/stores/settings/environment' import { useEnvironmentStore } from '@/stores/settings/environment'
@@ -194,6 +195,7 @@ export {
useExecutionStore, useExecutionStore,
useTerminalConsoleStore, useTerminalConsoleStore,
useCopilotStore, useCopilotStore,
useCustomToolsStore,
useVariablesStore, useVariablesStore,
useSubBlockStore, useSubBlockStore,
} }
@@ -220,7 +222,7 @@ export const resetAllStores = () => {
useExecutionStore.getState().reset() useExecutionStore.getState().reset()
useTerminalConsoleStore.setState({ entries: [], isOpen: false }) useTerminalConsoleStore.setState({ entries: [], isOpen: false })
useCopilotStore.setState({ messages: [], isSendingMessage: false, error: null }) useCopilotStore.setState({ messages: [], isSendingMessage: false, error: null })
// Custom tools are managed by React Query cache, not a Zustand store useCustomToolsStore.getState().reset()
// Variables store has no tracking to reset; registry hydrates // Variables store has no tracking to reset; registry hydrates
} }
@@ -233,6 +235,7 @@ export const logAllStores = () => {
execution: useExecutionStore.getState(), execution: useExecutionStore.getState(),
console: useTerminalConsoleStore.getState(), console: useTerminalConsoleStore.getState(),
copilot: useCopilotStore.getState(), copilot: useCopilotStore.getState(),
customTools: useCustomToolsStore.getState(),
subBlock: useSubBlockStore.getState(), subBlock: useSubBlockStore.getState(),
variables: useVariablesStore.getState(), variables: useVariablesStore.getState(),
} }

View File

@@ -771,50 +771,12 @@ function deepClone<T>(obj: T): T {
} }
} }
/**
* Recursively masks credential IDs in any value (string, object, or array).
* Used during serialization to ensure sensitive IDs are never persisted.
*/
function maskCredentialIdsInValue(value: any, credentialIds: Set<string>): any {
if (!value || credentialIds.size === 0) return value
if (typeof value === 'string') {
let masked = value
// Sort by length descending to mask longer IDs first
const sortedIds = Array.from(credentialIds).sort((a, b) => b.length - a.length)
for (const id of sortedIds) {
if (id && masked.includes(id)) {
masked = masked.split(id).join('••••••••')
}
}
return masked
}
if (Array.isArray(value)) {
return value.map((item) => maskCredentialIdsInValue(item, credentialIds))
}
if (typeof value === 'object') {
const masked: any = {}
for (const key of Object.keys(value)) {
masked[key] = maskCredentialIdsInValue(value[key], credentialIds)
}
return masked
}
return value
}
/** /**
* Serializes messages for database storage. * Serializes messages for database storage.
* Deep clones all fields to ensure proper JSON serialization. * Deep clones all fields to ensure proper JSON serialization.
* Masks sensitive credential IDs before persisting.
* This ensures they render identically when loaded back. * This ensures they render identically when loaded back.
*/ */
function serializeMessagesForDB(messages: CopilotMessage[]): any[] { function serializeMessagesForDB(messages: CopilotMessage[]): any[] {
// Get credential IDs to mask
const credentialIds = useCopilotStore.getState().sensitiveCredentialIds
const result = messages const result = messages
.map((msg) => { .map((msg) => {
// Deep clone the entire message to ensure all nested data is serializable // Deep clone the entire message to ensure all nested data is serializable
@@ -862,8 +824,7 @@ function serializeMessagesForDB(messages: CopilotMessage[]): any[] {
serialized.errorType = msg.errorType serialized.errorType = msg.errorType
} }
// Mask credential IDs in the serialized message before persisting return serialized
return maskCredentialIdsInValue(serialized, credentialIds)
}) })
.filter((msg) => { .filter((msg) => {
// Filter out empty assistant messages // Filter out empty assistant messages
@@ -1359,16 +1320,7 @@ const sseHandlers: Record<string, SSEHandler> = {
typeof def.hasInterrupt === 'function' typeof def.hasInterrupt === 'function'
? !!def.hasInterrupt(args || {}) ? !!def.hasInterrupt(args || {})
: !!def.hasInterrupt : !!def.hasInterrupt
// Check if tool is auto-allowed - if so, execute even if it has an interrupt if (!hasInterrupt && typeof def.execute === 'function') {
const { autoAllowedTools } = get()
const isAutoAllowed = name ? autoAllowedTools.includes(name) : false
if ((!hasInterrupt || isAutoAllowed) && typeof def.execute === 'function') {
if (isAutoAllowed && hasInterrupt) {
logger.info('[toolCallsById] Auto-executing tool with interrupt (auto-allowed)', {
id,
name,
})
}
const ctx = createExecutionContext({ toolCallId: id, toolName: name || 'unknown_tool' }) const ctx = createExecutionContext({ toolCallId: id, toolName: name || 'unknown_tool' })
// Defer executing transition by a tick to let pending render // Defer executing transition by a tick to let pending render
setTimeout(() => { setTimeout(() => {
@@ -1474,23 +1426,11 @@ const sseHandlers: Record<string, SSEHandler> = {
logger.warn('tool_call registry auto-exec check failed', { id, name, error: e }) logger.warn('tool_call registry auto-exec check failed', { id, name, error: e })
} }
// Class-based auto-exec for non-interrupt tools or auto-allowed tools // Class-based auto-exec for non-interrupt tools
try { try {
const inst = getClientTool(id) as any const inst = getClientTool(id) as any
const hasInterrupt = !!inst?.getInterruptDisplays?.() const hasInterrupt = !!inst?.getInterruptDisplays?.()
// Check if tool is auto-allowed - if so, execute even if it has an interrupt if (!hasInterrupt && typeof inst?.execute === 'function') {
const { autoAllowedTools: classAutoAllowed } = get()
const isClassAutoAllowed = name ? classAutoAllowed.includes(name) : false
if (
(!hasInterrupt || isClassAutoAllowed) &&
(typeof inst?.execute === 'function' || typeof inst?.handleAccept === 'function')
) {
if (isClassAutoAllowed && hasInterrupt) {
logger.info('[toolCallsById] Auto-executing class tool with interrupt (auto-allowed)', {
id,
name,
})
}
setTimeout(() => { setTimeout(() => {
// Guard against duplicate execution - check if already executing or terminal // Guard against duplicate execution - check if already executing or terminal
const currentState = get().toolCallsById[id]?.state const currentState = get().toolCallsById[id]?.state
@@ -1509,12 +1449,7 @@ const sseHandlers: Record<string, SSEHandler> = {
Promise.resolve() Promise.resolve()
.then(async () => { .then(async () => {
// Use handleAccept for tools with interrupts, execute for others await inst.execute(args || {})
if (hasInterrupt && typeof inst?.handleAccept === 'function') {
await inst.handleAccept(args || {})
} else {
await inst.execute(args || {})
}
// Success/error will be synced via registerToolStateSync // Success/error will be synced via registerToolStateSync
}) })
.catch(() => { .catch(() => {
@@ -1539,35 +1474,20 @@ const sseHandlers: Record<string, SSEHandler> = {
} }
} catch {} } catch {}
// Integration tools: Check auto-allowed or stay in pending state until user confirms // Integration tools: Stay in pending state until user confirms via buttons
// This handles tools like google_calendar_*, exa_*, gmail_read, etc. that aren't in the client registry // This handles tools like google_calendar_*, exa_*, gmail_read, etc. that aren't in the client registry
// Only relevant if mode is 'build' (agent) // Only relevant if mode is 'build' (agent)
const { mode, workflowId, autoAllowedTools, executeIntegrationTool } = get() const { mode, workflowId } = get()
if (mode === 'build' && workflowId) { if (mode === 'build' && workflowId) {
// Check if tool was NOT found in client registry // Check if tool was NOT found in client registry
const def = name ? getTool(name) : undefined const def = name ? getTool(name) : undefined
const inst = getClientTool(id) as any const inst = getClientTool(id) as any
if (!def && !inst && name) { if (!def && !inst && name) {
// Check if this integration tool is auto-allowed - if so, execute it immediately // Integration tools stay in pending state until user confirms
if (autoAllowedTools.includes(name)) { logger.info('[build mode] Integration tool awaiting user confirmation', {
logger.info('[build mode] Auto-executing integration tool (auto-allowed)', { id, name }) id,
// Defer to allow pending state to render briefly name,
setTimeout(() => { })
executeIntegrationTool(id).catch((err) => {
logger.error('[build mode] Auto-execute integration tool failed', {
id,
name,
error: err,
})
})
}, 0)
} else {
// Integration tools stay in pending state until user confirms
logger.info('[build mode] Integration tool awaiting user confirmation', {
id,
name,
})
}
} }
} }
}, },
@@ -2056,10 +1976,6 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
} }
// Execute client tools in parallel (non-blocking) - same pattern as main tool_call handler // Execute client tools in parallel (non-blocking) - same pattern as main tool_call handler
// Check if tool is auto-allowed
const { autoAllowedTools: subAgentAutoAllowed } = get()
const isSubAgentAutoAllowed = name ? subAgentAutoAllowed.includes(name) : false
try { try {
const def = getTool(name) const def = getTool(name)
if (def) { if (def) {
@@ -2067,15 +1983,8 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
typeof def.hasInterrupt === 'function' typeof def.hasInterrupt === 'function'
? !!def.hasInterrupt(args || {}) ? !!def.hasInterrupt(args || {})
: !!def.hasInterrupt : !!def.hasInterrupt
// Auto-execute if no interrupt OR if auto-allowed if (!hasInterrupt) {
if (!hasInterrupt || isSubAgentAutoAllowed) { // Auto-execute tools without interrupts - non-blocking
if (isSubAgentAutoAllowed && hasInterrupt) {
logger.info('[SubAgent] Auto-executing tool with interrupt (auto-allowed)', {
id,
name,
})
}
// Auto-execute tools - non-blocking
const ctx = createExecutionContext({ toolCallId: id, toolName: name }) const ctx = createExecutionContext({ toolCallId: id, toolName: name })
Promise.resolve() Promise.resolve()
.then(() => def.execute(ctx, args || {})) .then(() => def.execute(ctx, args || {}))
@@ -2092,22 +2001,9 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
const instance = getClientTool(id) const instance = getClientTool(id)
if (instance) { if (instance) {
const hasInterruptDisplays = !!instance.getInterruptDisplays?.() const hasInterruptDisplays = !!instance.getInterruptDisplays?.()
// Auto-execute if no interrupt OR if auto-allowed if (!hasInterruptDisplays) {
if (!hasInterruptDisplays || isSubAgentAutoAllowed) {
if (isSubAgentAutoAllowed && hasInterruptDisplays) {
logger.info('[SubAgent] Auto-executing class tool with interrupt (auto-allowed)', {
id,
name,
})
}
Promise.resolve() Promise.resolve()
.then(() => { .then(() => instance.execute(args || {}))
// Use handleAccept for tools with interrupts, execute for others
if (hasInterruptDisplays && typeof instance.handleAccept === 'function') {
return instance.handleAccept(args || {})
}
return instance.execute(args || {})
})
.catch((execErr: any) => { .catch((execErr: any) => {
logger.error('[SubAgent] Class tool execution failed', { logger.error('[SubAgent] Class tool execution failed', {
id, id,
@@ -2336,7 +2232,6 @@ const initialState = {
autoAllowedTools: [] as string[], autoAllowedTools: [] as string[],
messageQueue: [] as import('./types').QueuedMessage[], messageQueue: [] as import('./types').QueuedMessage[],
suppressAbortContinueOption: false, suppressAbortContinueOption: false,
sensitiveCredentialIds: new Set<string>(),
} }
export const useCopilotStore = create<CopilotStore>()( export const useCopilotStore = create<CopilotStore>()(
@@ -2719,12 +2614,6 @@ export const useCopilotStore = create<CopilotStore>()(
})) }))
} }
// Load sensitive credential IDs for masking before streaming starts
await get().loadSensitiveCredentialIds()
// Ensure auto-allowed tools are loaded before tool calls arrive
await get().loadAutoAllowedTools()
let newMessages: CopilotMessage[] let newMessages: CopilotMessage[]
if (revertState) { if (revertState) {
const currentMessages = get().messages const currentMessages = get().messages
@@ -3787,16 +3676,6 @@ export const useCopilotStore = create<CopilotStore>()(
const { id, name, params } = toolCall const { id, name, params } = toolCall
// Guard against double execution - skip if already executing or in terminal state
if (toolCall.state === ClientToolCallState.executing || isTerminalState(toolCall.state)) {
logger.info('[executeIntegrationTool] Skipping - already executing or terminal', {
id,
name,
state: toolCall.state,
})
return
}
// Set to executing state // Set to executing state
const executingMap = { ...get().toolCallsById } const executingMap = { ...get().toolCallsById }
executingMap[id] = { executingMap[id] = {
@@ -3945,46 +3824,6 @@ export const useCopilotStore = create<CopilotStore>()(
const data = await res.json() const data = await res.json()
set({ autoAllowedTools: data.autoAllowedTools || [] }) set({ autoAllowedTools: data.autoAllowedTools || [] })
logger.info('[AutoAllowedTools] Added tool', { toolId }) logger.info('[AutoAllowedTools] Added tool', { toolId })
// Auto-execute all pending tools of the same type
const { toolCallsById, executeIntegrationTool } = get()
const pendingToolCalls = Object.values(toolCallsById).filter(
(tc) => tc.name === toolId && tc.state === ClientToolCallState.pending
)
if (pendingToolCalls.length > 0) {
const isIntegrationTool = !CLASS_TOOL_METADATA[toolId]
logger.info('[AutoAllowedTools] Auto-executing pending tools', {
toolId,
count: pendingToolCalls.length,
isIntegrationTool,
})
for (const tc of pendingToolCalls) {
if (isIntegrationTool) {
// Integration tools use executeIntegrationTool
executeIntegrationTool(tc.id).catch((err) => {
logger.error('[AutoAllowedTools] Auto-execute pending integration tool failed', {
toolCallId: tc.id,
toolId,
error: err,
})
})
} else {
// Client tools with interrupts use handleAccept
const inst = getClientTool(tc.id) as any
if (inst && typeof inst.handleAccept === 'function') {
Promise.resolve()
.then(() => inst.handleAccept(tc.params || {}))
.catch((err: any) => {
logger.error('[AutoAllowedTools] Auto-execute pending client tool failed', {
toolCallId: tc.id,
toolId,
error: err,
})
})
}
}
}
}
} }
} catch (err) { } catch (err) {
logger.error('[AutoAllowedTools] Failed to add tool', { toolId, error: err }) logger.error('[AutoAllowedTools] Failed to add tool', { toolId, error: err })
@@ -4014,57 +3853,6 @@ export const useCopilotStore = create<CopilotStore>()(
return autoAllowedTools.includes(toolId) return autoAllowedTools.includes(toolId)
}, },
// Credential masking
loadSensitiveCredentialIds: async () => {
try {
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolName: 'get_credentials', payload: {} }),
})
if (!res.ok) {
logger.warn('[loadSensitiveCredentialIds] Failed to fetch credentials', {
status: res.status,
})
return
}
const json = await res.json()
// Credentials are at result.oauth.connected.credentials
const credentials = json?.result?.oauth?.connected?.credentials || []
logger.info('[loadSensitiveCredentialIds] Response', {
hasResult: !!json?.result,
credentialCount: credentials.length,
})
const ids = new Set<string>()
for (const cred of credentials) {
if (cred?.id) {
ids.add(cred.id)
}
}
set({ sensitiveCredentialIds: ids })
logger.info('[loadSensitiveCredentialIds] Loaded credential IDs', {
count: ids.size,
})
} catch (err) {
logger.warn('[loadSensitiveCredentialIds] Error loading credentials', err)
}
},
maskCredentialValue: (value: string) => {
const { sensitiveCredentialIds } = get()
if (!value || sensitiveCredentialIds.size === 0) return value
let masked = value
// Sort by length descending to mask longer IDs first
const sortedIds = Array.from(sensitiveCredentialIds).sort((a, b) => b.length - a.length)
for (const id of sortedIds) {
if (id && masked.includes(id)) {
masked = masked.split(id).join('••••••••')
}
}
return masked
},
// Message queue actions // Message queue actions
addToQueue: (message, options) => { addToQueue: (message, options) => {
const queuedMessage: import('./types').QueuedMessage = { const queuedMessage: import('./types').QueuedMessage = {

View File

@@ -156,9 +156,6 @@ export interface CopilotState {
// Message queue for messages sent while another is in progress // Message queue for messages sent while another is in progress
messageQueue: QueuedMessage[] messageQueue: QueuedMessage[]
// Credential IDs to mask in UI (for sensitive data protection)
sensitiveCredentialIds: Set<string>
} }
export interface CopilotActions { export interface CopilotActions {
@@ -238,10 +235,6 @@ export interface CopilotActions {
removeAutoAllowedTool: (toolId: string) => Promise<void> removeAutoAllowedTool: (toolId: string) => Promise<void>
isToolAutoAllowed: (toolId: string) => boolean isToolAutoAllowed: (toolId: string) => boolean
// Credential masking
loadSensitiveCredentialIds: () => Promise<void>
maskCredentialValue: (value: string) => string
// Message queue actions // Message queue actions
addToQueue: ( addToQueue: (
message: string, message: string,

View File

@@ -1,11 +1,11 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { create } from 'zustand' import { create } from 'zustand'
import { devtools } from 'zustand/middleware' import { devtools } from 'zustand/middleware'
import { normalizeName } from '@/executor/constants'
import { useOperationQueueStore } from '@/stores/operation-queue/store' import { useOperationQueueStore } from '@/stores/operation-queue/store'
import type { Variable, VariablesStore } from '@/stores/panel/variables/types' import type { Variable, VariablesStore } from '@/stores/panel/variables/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { normalizeName } from '@/stores/workflows/utils'
const logger = createLogger('VariablesStore') const logger = createLogger('VariablesStore')

View File

@@ -2,7 +2,6 @@ import { createLogger } from '@sim/logger'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { create } from 'zustand' import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware' import { devtools, persist } from 'zustand/middleware'
import { normalizeName } from '@/executor/constants'
import type { import type {
Variable, Variable,
VariablesDimensions, VariablesDimensions,
@@ -12,6 +11,7 @@ import type {
} from '@/stores/variables/types' } from '@/stores/variables/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { normalizeName } from '@/stores/workflows/utils'
const logger = createLogger('VariablesModalStore') const logger = createLogger('VariablesModalStore')

View File

@@ -6,8 +6,7 @@ import {
createStarterBlock, createStarterBlock,
} from '@sim/testing' } from '@sim/testing'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { normalizeName } from '@/executor/constants' import { getUniqueBlockName, normalizeName } from './utils'
import { getUniqueBlockName } from './utils'
describe('normalizeName', () => { describe('normalizeName', () => {
it.concurrent('should convert to lowercase', () => { it.concurrent('should convert to lowercase', () => {

View File

@@ -17,6 +17,8 @@ import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
const WEBHOOK_SUBBLOCK_FIELDS = ['webhookId', 'triggerPath'] const WEBHOOK_SUBBLOCK_FIELDS = ['webhookId', 'triggerPath']
export { normalizeName }
export function filterNewEdges(edgesToAdd: Edge[], currentEdges: Edge[]): Edge[] { export function filterNewEdges(edgesToAdd: Edge[], currentEdges: Edge[]): Edge[] {
return edgesToAdd.filter((edge) => { return edgesToAdd.filter((edge) => {
if (edge.source === edge.target) return false if (edge.source === edge.target) return false
@@ -137,7 +139,7 @@ export function prepareBlockState(options: PrepareBlockStateOptions): BlockState
} }
} else if (subBlock.defaultValue !== undefined) { } else if (subBlock.defaultValue !== undefined) {
initialValue = subBlock.defaultValue initialValue = subBlock.defaultValue
} else if (subBlock.type === 'input-format' || subBlock.type === 'response-format') { } else if (subBlock.type === 'input-format') {
initialValue = [ initialValue = [
{ {
id: crypto.randomUUID(), id: crypto.randomUUID(),

View File

@@ -7,10 +7,14 @@ import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { getBlock } from '@/blocks' import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import { normalizeName } from '@/executor/constants'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { filterNewEdges, getUniqueBlockName, mergeSubblockState } from '@/stores/workflows/utils' import {
filterNewEdges,
getUniqueBlockName,
mergeSubblockState,
normalizeName,
} from '@/stores/workflows/utils'
import type { import type {
Position, Position,
SubBlockState, SubBlockState,

View File

@@ -14,54 +14,6 @@ import {
type MockFetchResponse, type MockFetchResponse,
} from '@sim/testing' } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Mock custom tools query - must be hoisted before imports
vi.mock('@/hooks/queries/custom-tools', () => ({
getCustomTool: (toolId: string) => {
if (toolId === 'custom-tool-123') {
return {
id: 'custom-tool-123',
title: 'Custom Weather Tool',
code: 'return { result: "Weather data" }',
schema: {
function: {
description: 'Get weather information',
parameters: {
type: 'object',
properties: {
location: { type: 'string', description: 'City name' },
unit: { type: 'string', description: 'Unit (metric/imperial)' },
},
required: ['location'],
},
},
},
}
}
return undefined
},
getCustomTools: () => [
{
id: 'custom-tool-123',
title: 'Custom Weather Tool',
code: 'return { result: "Weather data" }',
schema: {
function: {
description: 'Get weather information',
parameters: {
type: 'object',
properties: {
location: { type: 'string', description: 'City name' },
unit: { type: 'string', description: 'Unit (metric/imperial)' },
},
required: ['location'],
},
},
},
},
],
}))
import { executeTool } from '@/tools/index' import { executeTool } from '@/tools/index'
import { tools } from '@/tools/registry' import { tools } from '@/tools/registry'
import { getTool } from '@/tools/utils' import { getTool } from '@/tools/utils'
@@ -142,6 +94,73 @@ describe('Tools Registry', () => {
}) })
describe('Custom Tools', () => { describe('Custom Tools', () => {
beforeEach(() => {
vi.mock('@/stores/custom-tools', () => ({
useCustomToolsStore: {
getState: () => ({
getTool: (id: string) => {
if (id === 'custom-tool-123') {
return {
id: 'custom-tool-123',
title: 'Custom Weather Tool',
code: 'return { result: "Weather data" }',
schema: {
function: {
description: 'Get weather information',
parameters: {
type: 'object',
properties: {
location: { type: 'string', description: 'City name' },
unit: { type: 'string', description: 'Unit (metric/imperial)' },
},
required: ['location'],
},
},
},
}
}
return undefined
},
getAllTools: () => [
{
id: 'custom-tool-123',
title: 'Custom Weather Tool',
code: 'return { result: "Weather data" }',
schema: {
function: {
description: 'Get weather information',
parameters: {
type: 'object',
properties: {
location: { type: 'string', description: 'City name' },
unit: { type: 'string', description: 'Unit (metric/imperial)' },
},
required: ['location'],
},
},
},
},
],
}),
},
}))
vi.mock('@/stores/settings/environment', () => ({
useEnvironmentStore: {
getState: () => ({
getAllVariables: () => ({
API_KEY: { value: 'test-api-key' },
BASE_URL: { value: 'https://test-base-url.com' },
}),
}),
},
}))
})
afterEach(() => {
vi.resetAllMocks()
})
it('should get custom tool by ID', () => { it('should get custom tool by ID', () => {
const customTool = getTool('custom_custom-tool-123') const customTool = getTool('custom_custom-tool-123')
expect(customTool).toBeDefined() expect(customTool).toBeDefined()

View File

@@ -643,22 +643,13 @@ async function executeToolRequest(
}) })
const responseHeaders = new Headers(secureResponse.headers.toRecord()) const responseHeaders = new Headers(secureResponse.headers.toRecord())
const nullBodyStatuses = new Set([101, 204, 205, 304]) const bodyBuffer = await secureResponse.arrayBuffer()
if (nullBodyStatuses.has(secureResponse.status)) { response = new Response(bodyBuffer, {
response = new Response(null, { status: secureResponse.status,
status: secureResponse.status, statusText: secureResponse.statusText,
statusText: secureResponse.statusText, headers: responseHeaders,
headers: responseHeaders, })
})
} else {
const bodyBuffer = await secureResponse.arrayBuffer()
response = new Response(bodyBuffer, {
status: secureResponse.status,
statusText: secureResponse.statusText,
headers: responseHeaders,
})
}
} }
// For non-OK responses, attempt JSON first; if parsing fails, fall back to text // For non-OK responses, attempt JSON first; if parsing fails, fall back to text
@@ -702,9 +693,11 @@ async function executeToolRequest(
throw errorToTransform throw errorToTransform
} }
// Parse response data once with guard for empty 202 bodies
let responseData let responseData
const status = response.status const status = response.status
if (status === 202 || status === 204 || status === 205) { if (status === 202) {
// Many APIs (e.g., Microsoft Graph) return 202 with empty body
responseData = { status } responseData = { status }
} else { } else {
if (tool.transformResponse) { if (tool.transformResponse) {

View File

@@ -1,7 +1,7 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
import { AGENT, isCustomTool } from '@/executor/constants' import { AGENT, isCustomTool } from '@/executor/constants'
import { getCustomTool } from '@/hooks/queries/custom-tools' import { useCustomToolsStore } from '@/stores/custom-tools'
import { useEnvironmentStore } from '@/stores/settings/environment' import { useEnvironmentStore } from '@/stores/settings/environment'
import { extractErrorMessage } from '@/tools/error-extractors' import { extractErrorMessage } from '@/tools/error-extractors'
import { tools } from '@/tools/registry' import { tools } from '@/tools/registry'
@@ -335,10 +335,17 @@ export function getTool(toolId: string): ToolConfig | undefined {
// Check if it's a custom tool // Check if it's a custom tool
if (isCustomTool(toolId) && typeof window !== 'undefined') { if (isCustomTool(toolId) && typeof window !== 'undefined') {
// Only try to use the sync version on the client // Only try to use the sync version on the client
const customToolsStore = useCustomToolsStore.getState()
const identifier = toolId.slice(AGENT.CUSTOM_TOOL_PREFIX.length) const identifier = toolId.slice(AGENT.CUSTOM_TOOL_PREFIX.length)
// Try to find the tool from query cache (extracts workspaceId from URL) // Try to find the tool directly by ID first
const customTool = getCustomTool(identifier) let customTool = customToolsStore.getTool(identifier)
// If not found by ID, try to find by title (for backward compatibility)
if (!customTool) {
const allTools = customToolsStore.getAllTools()
customTool = allTools.find((tool) => tool.title === identifier)
}
if (customTool) { if (customTool) {
return createToolConfig(customTool, toolId) return createToolConfig(customTool, toolId)
@@ -360,7 +367,7 @@ export async function getToolAsync(
// Check if it's a custom tool // Check if it's a custom tool
if (isCustomTool(toolId)) { if (isCustomTool(toolId)) {
return fetchCustomToolFromAPI(toolId, workflowId) return getCustomTool(toolId, workflowId)
} }
return undefined return undefined
@@ -404,8 +411,8 @@ function createToolConfig(customTool: any, customToolId: string): ToolConfig {
} }
} }
// Create a tool config from a custom tool definition by fetching from API // Create a tool config from a custom tool definition
async function fetchCustomToolFromAPI( async function getCustomTool(
customToolId: string, customToolId: string,
workflowId?: string workflowId?: string
): Promise<ToolConfig | undefined> { ): Promise<ToolConfig | undefined> {

View File

@@ -77,10 +77,7 @@ app:
# Node environment # Node environment
NODE_ENV: "production" NODE_ENV: "production"
NEXT_TELEMETRY_DISABLED: "1" NEXT_TELEMETRY_DISABLED: "1"
# Telemetry & Monitoring
TELEMETRY_ENDPOINT: "" # OTLP endpoint for traces/logs (e.g., "https://otlp-collector:4318/v1/traces")
# Authentication and encryption secrets (REQUIRED for production) # Authentication and encryption secrets (REQUIRED for production)
# Generate secure 32-character secrets using: openssl rand -hex 32 # Generate secure 32-character secrets using: openssl rand -hex 32
BETTER_AUTH_SECRET: "" # REQUIRED - set via --set flag or external secret manager BETTER_AUTH_SECRET: "" # REQUIRED - set via --set flag or external secret manager
@@ -104,12 +101,8 @@ app:
# OAuth Integration Credentials (leave empty if not using) # OAuth Integration Credentials (leave empty if not using)
GOOGLE_CLIENT_ID: "" # Google OAuth client ID GOOGLE_CLIENT_ID: "" # Google OAuth client ID
GOOGLE_CLIENT_SECRET: "" # Google OAuth client secret GOOGLE_CLIENT_SECRET: "" # Google OAuth client secret
GITHUB_CLIENT_ID: "" # GitHub OAuth client ID GITHUB_CLIENT_ID: "" # GitHub OAuth client ID
GITHUB_CLIENT_SECRET: "" # GitHub OAuth client secret GITHUB_CLIENT_SECRET: "" # GitHub OAuth client secret
# Google Vertex AI Configuration
VERTEX_PROJECT: "" # Google Cloud project ID for Vertex AI
VERTEX_LOCATION: "us-central1" # Google Cloud region for Vertex AI (e.g., "us-central1")
# AI Provider API Keys (leave empty if not using) # AI Provider API Keys (leave empty if not using)
OPENAI_API_KEY: "" # Primary OpenAI API key OPENAI_API_KEY: "" # Primary OpenAI API key
@@ -150,15 +143,6 @@ app:
ALLOWED_LOGIN_EMAILS: "" # Comma-separated list of allowed email addresses for login ALLOWED_LOGIN_EMAILS: "" # Comma-separated list of allowed email addresses for login
ALLOWED_LOGIN_DOMAINS: "" # Comma-separated list of allowed email domains for login ALLOWED_LOGIN_DOMAINS: "" # Comma-separated list of allowed email domains for login
# Admin API Configuration
ADMIN_API_KEY: "" # Admin API key for organization/user management (generate with: openssl rand -hex 32)
# Organizations & Permission Groups
ACCESS_CONTROL_ENABLED: "false" # Enable permission groups feature ("true" to enable)
ORGANIZATIONS_ENABLED: "false" # Enable organizations feature ("true" to enable)
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: "false" # Show permission groups UI ("true" to enable)
NEXT_PUBLIC_ORGANIZATIONS_ENABLED: "false" # Show organizations UI ("true" to enable)
# LLM Provider/Model Restrictions (leave empty if not restricting) # LLM Provider/Model Restrictions (leave empty if not restricting)
BLACKLISTED_PROVIDERS: "" # Comma-separated provider IDs to hide from UI (e.g., "openai,anthropic,google") BLACKLISTED_PROVIDERS: "" # Comma-separated provider IDs to hide from UI (e.g., "openai,anthropic,google")
BLACKLISTED_MODELS: "" # Comma-separated model names/prefixes to hide (e.g., "gpt-4,claude-*") BLACKLISTED_MODELS: "" # Comma-separated model names/prefixes to hide (e.g., "gpt-4,claude-*")

View File

@@ -33,20 +33,22 @@ export interface LoggerConfig {
enabled?: boolean enabled?: boolean
} }
const getNodeEnv = (): string => { /**
* Get environment variable value
* Works in any JavaScript runtime (Node.js, Bun, etc.)
*/
const getEnvVar = (key: string): string | undefined => {
if (typeof process !== 'undefined' && process.env) { if (typeof process !== 'undefined' && process.env) {
return process.env.NODE_ENV || 'development' return process.env[key]
}
return 'development'
}
const getLogLevel = (): string | undefined => {
if (typeof process !== 'undefined' && process.env) {
return process.env.LOG_LEVEL
} }
return undefined return undefined
} }
/**
* Get the current environment (development, production, test)
*/
const getNodeEnv = (): string => getEnvVar('NODE_ENV') || 'development'
/** /**
* Get the minimum log level from environment variable or use defaults * Get the minimum log level from environment variable or use defaults
* - Development: DEBUG (show all logs) * - Development: DEBUG (show all logs)
@@ -54,7 +56,7 @@ const getLogLevel = (): string | undefined => {
* - Test: ERROR (only show errors in tests) * - Test: ERROR (only show errors in tests)
*/ */
const getMinLogLevel = (): LogLevel => { const getMinLogLevel = (): LogLevel => {
const logLevelEnv = getLogLevel() const logLevelEnv = getEnvVar('LOG_LEVEL')
if (logLevelEnv && Object.values(LogLevel).includes(logLevelEnv as LogLevel)) { if (logLevelEnv && Object.values(LogLevel).includes(logLevelEnv as LogLevel)) {
return logLevelEnv as LogLevel return logLevelEnv as LogLevel
} }
@@ -118,6 +120,7 @@ const formatObject = (obj: unknown, isDev: boolean): string => {
stack: isDev ? obj.stack : undefined, stack: isDev ? obj.stack : undefined,
name: obj.name, name: obj.name,
} }
// Copy any additional enumerable properties from the error
for (const key of Object.keys(obj)) { for (const key of Object.keys(obj)) {
if (!(key in errorObj)) { if (!(key in errorObj)) {
errorObj[key] = (obj as unknown as Record<string, unknown>)[key] errorObj[key] = (obj as unknown as Record<string, unknown>)[key]
@@ -178,6 +181,7 @@ export class Logger {
private shouldLog(level: LogLevel): boolean { private shouldLog(level: LogLevel): boolean {
if (!this.config.enabled) return false if (!this.config.enabled) return false
// In production, only log on server-side (where window is undefined)
if (getNodeEnv() === 'production' && typeof window !== 'undefined') { if (getNodeEnv() === 'production' && typeof window !== 'undefined') {
return false return false
} }