Compare commits

..

37 Commits

Author SHA1 Message Date
Waleed Latif
4f09d07383 fix(admin): reject NaN and Infinity in amount validation 2026-01-23 10:46:29 -08:00
Waleed Latif
3bb85f2218 fix(admin): use existing credit functions and handle enterprise seats 2026-01-23 10:44:03 -08:00
Waleed Latif
1e89d147ed feat(admin): add credits endpoint to issue credits to users 2026-01-23 09:59:10 -08:00
Waleed
9b72b52b33 feat(blog): v0.5 release post (#2953)
* feat(blog): v0.5 post

* improvement(blog): simplify title and remove code block header

- Simplified blog title from "Introducing Sim Studio v0.5" to "Introducing Sim v0.5"
- Removed language label header and copy button from code blocks for cleaner appearance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* ack PR comments

* small styling improvements

* created system to create post-specific components

* updated componnet

* cache invalidation

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 09:07:53 -08:00
Emir Karabeg
1467862488 improvement(logs): trace span, details (#2952)
* improvement(action-bar): ordering

* improvement(logs): details, trace span
2026-01-22 19:50:20 -08:00
Waleed
7f2262857c improvement(kb): add document filtering, select all, and React Query migration (#2951)
* improvement(kb): add document filtering, select all, and React Query migration

* test(kb): update tests for enabledFilter and removed userId params

* fix(kb): remove non-null assertion, add explicit guard
2026-01-22 19:25:16 -08:00
Vikhyath Mondreti
1b309b50e6 fix(idempotency): add conflict target to atomicallyClaimDb query + remove redundant db namespace tracking (#2950)
* fix(idempotency): add conflict target to atomicallyClaimDb query

* delete needs to account for namespace

* simplify namespace filtering logic

* fix cleanup

* consistent target
2026-01-22 18:38:08 -08:00
Waleed
f765b83a26 chore(deps): bump posthog-js to 1.334.1 (#2948) 2026-01-22 18:06:05 -08:00
Vikhyath Mondreti
aa99db6fdd fix(subflows): tag dropdown + resolution logic (#2949)
* fix(subflows): tag dropdown + resolution logic

* fixes;

* revert parallel change
2026-01-22 17:57:55 -08:00
Waleed
748793e07d fix(executor): handle condition dead-end branches in loops (#2944) 2026-01-22 13:30:11 -08:00
Siddharth Ganesan
91da7e183a fix(copilot): always allow, credential masking (#2947)
* Fix always allow, credential validation

* Credential masking

* Autoload
2026-01-22 13:07:16 -08:00
Waleed
ab09a5ad23 feat(router): expose reasoning output in router v2 block (#2945) 2026-01-22 12:43:57 -08:00
Vikhyath Mondreti
fcd0240db6 fix(resolver): consolidate reference resolution (#2941)
* fix(resolver): consolidate code to resolve references

* fix edge cases

* use already formatted error

* fix multi index

* fix backwards compat reachability

* handle backwards compatibility accurately

* use shared constant correctly
2026-01-22 12:38:50 -08:00
Waleed
4e4149792a fix(gmail): expose messageId field in read email block (#2943) 2026-01-22 11:46:34 -08:00
Waleed
9a8b591257 improvement(helm): add per-deployment extraVolumes support (#2942) 2026-01-22 11:35:23 -08:00
Waleed
f3ae3f8442 fix(executor): stop parallel execution when block errors (#2940) 2026-01-22 11:34:40 -08:00
Waleed
66dfe2c6b2 improvement(workflow-item): stabilize avatar layout and fix name truncation (#2939)
* improvement(workflow-item): stabilize avatar layout and fix name truncation

* fix(avatars): revert overflow bg to hardcoded color for contrast
2026-01-22 11:26:47 -08:00
Waleed
376f7cb571 fix(auth): add genericOAuth providers to trustedProviders (#2937) 2026-01-21 22:44:30 -08:00
Vikhyath Mondreti
42159c23b9 improvement(copilot): tool configs to show nested props (#2936) 2026-01-21 20:02:59 -08:00
Emir Karabeg
2f0f246002 feat(workflow-block): preview (#2935) 2026-01-21 19:12:28 -08:00
Waleed
900d3ef9ea fix(workflow-selector): use dedicated selector for workflow dropdown (#2934) 2026-01-21 18:38:03 -08:00
Waleed
f3fcc28f89 fix(auth): handle EMAIL_NOT_VERIFIED in onError callback (#2932)
* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback

* refactor(auth): extract redirectToVerify helper to reduce duplication
2026-01-21 18:34:49 -08:00
Vikhyath Mondreti
7cfdf46724 fix(token-refresh): microsoft, notion, x, linear (#2933)
* fix(microsoft): proactive refresh needed

* fix(x): missing token refresh flag

* notion and linear missing flag too

* address bugbot comment
2026-01-21 18:30:53 -08:00
Vikhyath Mondreti
d681451297 fix(null-bodies): empty bodies handling (#2931)
* fix(null-statuses): empty bodies handling

* address bugbot comment
2026-01-21 18:10:33 -08:00
Waleed
5987a6d060 fix(custom-tools): remove unsafe title fallback in getCustomTool (#2929)
* fix(custom-tools): remove unsafe title fallback in getCustomTool

* fix(custom-tools): restore title fallback in getCustomTool lookup

Custom tools are referenced by title (custom_${title}), not database ID.
The title fallback is required for client-side tool resolution to work.
2026-01-21 17:36:10 -08:00
Waleed
e2ccefb2f4 improvement(ui): use BrandedButton and BrandedLink components (#2930)
- Refactor auth forms to use BrandedButton component
- Add BrandedLink component for changelog page
- Reduce code duplication in login, signup, reset-password forms
- Update star count default value
2026-01-21 17:25:30 -08:00
Waleed
103b31a569 fix(stores): remove dead code causing log spam on startup (#2927)
* fix(stores): remove dead code causing log spam on startup

* fix(stores): replace custom tools zustand store with react query cache
2026-01-21 16:08:26 -08:00
Waleed
004e058353 fix(messages-input): fix cursor alignment and auto-resize with overlay (#2926)
* fix(messages-input): fix cursor alignment and auto-resize with overlay

* fixed remaining zustand warnings
2026-01-21 15:30:13 -08:00
Vikhyath Mondreti
5157f0bbb2 fix(resolver): agent response format, input formats, root level (#2925)
* fix(resolvers): agent response format, input formats, root level

* fix response block initial seeding

* fix tests
2026-01-21 14:55:23 -08:00
Waleed
8bbcf31b83 fix(action-bar): duplicate subflows with children (#2923)
* fix(action-bar): duplicate subflows with children

* fix(action-bar): add validateTriggerPaste for subflow duplicate
2026-01-21 14:54:29 -08:00
Waleed
9e814315dd fix(auth): improve reset password flow and consolidate brand detection (#2924)
* fix(auth): improve reset password flow and consolidate brand detection

* fix(auth): set errorHandled for EMAIL_NOT_VERIFIED to prevent duplicate error

* fix(auth): clear success message on login errors

* chore(auth): fix import order per lint
2026-01-21 14:42:14 -08:00
Waleed
0ea0256623 chore(helm): add env vars for Vertex AI, orgs, and telemetry (#2922) 2026-01-21 11:36:16 -08:00
Waleed
fb8868c854 fix(notifications): text overflow with line-clamp (#2921) 2026-01-21 10:20:21 -08:00
Waleed
ea4964052d fix(logger): use direct env access for webpack inlining (#2920) 2026-01-21 10:14:40 -08:00
Waleed
268e2f114f fix(zustand): updated to useShallow from deprecated createWithEqualityFn (#2919) 2026-01-21 09:47:48 -08:00
Vikhyath Mondreti
5988d0e46f fix(ring): duplicate should clear original block (#2916)
* fix(ring): duplicate should clear original block

* rename correctly
2026-01-21 02:40:58 -08:00
Vikhyath Mondreti
145db9d8c3 fix(http): options not parsed accurately (#2914)
* fix(http): options not parsed accurately

* fix lint

* remove boilerplate code'
2026-01-21 01:36:29 -08:00
190 changed files with 17190 additions and 3271 deletions

View File

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

View File

@@ -1,12 +1,12 @@
'use client'
import { useEffect, useState } from 'react'
import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useState } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/core/utils/cn'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
interface RequestResetFormProps {
email: string
@@ -27,36 +27,6 @@ export function RequestResetForm({
statusMessage,
className,
}: RequestResetFormProps) {
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
const [isButtonHovered, setIsButtonHovered] = useState(false)
useEffect(() => {
const checkCustomBrand = () => {
const computedStyle = getComputedStyle(document.documentElement)
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
if (brandAccent && brandAccent !== '#6f3dfa') {
setButtonClass('branded-button-custom')
} else {
setButtonClass('branded-button-gradient')
}
}
checkCustomBrand()
window.addEventListener('resize', checkCustomBrand)
const observer = new MutationObserver(checkCustomBrand)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style', 'class'],
})
return () => {
window.removeEventListener('resize', checkCustomBrand)
observer.disconnect()
}
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
onSubmit(email)
@@ -94,24 +64,14 @@ export function RequestResetForm({
)}
</div>
<Button
<BrandedButton
type='submit'
disabled={isSubmitting}
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'
loading={isSubmitting}
loadingText='Sending'
>
<span className='flex items-center gap-1'>
{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>
Send Reset Link
</BrandedButton>
</form>
)
}
@@ -138,35 +98,6 @@ export function SetNewPasswordForm({
const [validationMessage, setValidationMessage] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
const [isButtonHovered, setIsButtonHovered] = useState(false)
useEffect(() => {
const checkCustomBrand = () => {
const computedStyle = getComputedStyle(document.documentElement)
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
if (brandAccent && brandAccent !== '#6f3dfa') {
setButtonClass('branded-button-custom')
} else {
setButtonClass('branded-button-gradient')
}
}
checkCustomBrand()
window.addEventListener('resize', checkCustomBrand)
const observer = new MutationObserver(checkCustomBrand)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style', 'class'],
})
return () => {
window.removeEventListener('resize', checkCustomBrand)
observer.disconnect()
}
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -296,24 +227,14 @@ export function SetNewPasswordForm({
)}
</div>
<Button
disabled={isSubmitting || !token}
<BrandedButton
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={isSubmitting || !token}
loading={isSubmitting}
loadingText='Resetting'
>
<span className='flex items-center gap-1'>
{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>
Reset Password
</BrandedButton>
</form>
)
}

View File

@@ -2,10 +2,9 @@
import { Suspense, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
import { Eye, EyeOff } from 'lucide-react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { client, useSession } from '@/lib/auth/auth-client'
@@ -14,8 +13,10 @@ import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
const logger = createLogger('SignupForm')
@@ -95,8 +96,7 @@ function SignupFormContent({
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [redirectUrl, setRedirectUrl] = useState('')
const [isInviteFlow, setIsInviteFlow] = useState(false)
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
const [isButtonHovered, setIsButtonHovered] = useState(false)
const buttonClass = useBrandedButtonClass()
const [name, setName] = useState('')
const [nameErrors, setNameErrors] = useState<string[]>([])
@@ -126,31 +126,6 @@ function SignupFormContent({
if (inviteFlowParam === 'true') {
setIsInviteFlow(true)
}
const checkCustomBrand = () => {
const computedStyle = getComputedStyle(document.documentElement)
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
if (brandAccent && brandAccent !== '#6f3dfa') {
setButtonClass('branded-button-custom')
} else {
setButtonClass('branded-button-gradient')
}
}
checkCustomBrand()
window.addEventListener('resize', checkCustomBrand)
const observer = new MutationObserver(checkCustomBrand)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style', 'class'],
})
return () => {
window.removeEventListener('resize', checkCustomBrand)
observer.disconnect()
}
}, [searchParams])
const validatePassword = (passwordValue: string): string[] => {
@@ -500,24 +475,14 @@ function SignupFormContent({
</div>
</div>
<Button
<BrandedButton
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}
loading={isLoading}
loadingText='Creating account'
>
<span className='flex items-center gap-1'>
{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>
Create account
</BrandedButton>
</form>
)}

View File

@@ -13,6 +13,7 @@ import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
const logger = createLogger('SSOForm')
@@ -57,7 +58,7 @@ export default function SSOForm() {
const [email, setEmail] = useState('')
const [emailErrors, setEmailErrors] = useState<string[]>([])
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
const buttonClass = useBrandedButtonClass()
const [callbackUrl, setCallbackUrl] = useState('/workspace')
useEffect(() => {
@@ -90,31 +91,6 @@ export default function SSOForm() {
setShowEmailValidationError(true)
}
}
const checkCustomBrand = () => {
const computedStyle = getComputedStyle(document.documentElement)
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
if (brandAccent && brandAccent !== '#6f3dfa') {
setButtonClass('branded-button-custom')
} else {
setButtonClass('branded-button-gradient')
}
}
checkCustomBrand()
window.addEventListener('resize', checkCustomBrand)
const observer = new MutationObserver(checkCustomBrand)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style', 'class'],
})
return () => {
window.removeEventListener('resize', checkCustomBrand)
observer.disconnect()
}
}, [searchParams])
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {

View File

@@ -8,6 +8,7 @@ import { cn } from '@/lib/core/utils/cn'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { useVerification } from '@/app/(auth)/verify/use-verification'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
interface VerifyContentProps {
hasEmailService: boolean
@@ -58,34 +59,7 @@ function VerificationForm({
setCountdown(30)
}
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
useEffect(() => {
const checkCustomBrand = () => {
const computedStyle = getComputedStyle(document.documentElement)
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
if (brandAccent && brandAccent !== '#6f3dfa') {
setButtonClass('branded-button-custom')
} else {
setButtonClass('branded-button-gradient')
}
}
checkCustomBrand()
window.addEventListener('resize', checkCustomBrand)
const observer = new MutationObserver(checkCustomBrand)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style', 'class'],
})
return () => {
window.removeEventListener('resize', checkCustomBrand)
observer.disconnect()
}
}, [])
const buttonClass = useBrandedButtonClass()
return (
<>

View File

@@ -4,7 +4,6 @@ import { useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { X } from 'lucide-react'
import { Textarea } from '@/components/emcn'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
@@ -18,6 +17,7 @@ import { isHosted } from '@/lib/core/config/feature-flags'
import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
import Footer from '@/app/(landing)/components/footer/footer'
import Nav from '@/app/(landing)/components/nav/nav'
@@ -493,18 +493,17 @@ export default function CareersPage() {
{/* Submit Button */}
<div className='flex justify-end pt-2'>
<Button
<BrandedButton
type='submit'
disabled={isSubmitting || submitStatus === 'success'}
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'
size='lg'
loading={isSubmitting}
loadingText='Submitting'
showArrow={false}
fullWidth={false}
className='min-w-[200px]'
>
{isSubmitting
? 'Submitting...'
: submitStatus === 'success'
? 'Submitted'
: 'Submit Application'}
</Button>
{submitStatus === 'success' ? 'Submitted' : 'Submit Application'}
</BrandedButton>
</div>
</form>
</section>

View File

@@ -11,6 +11,7 @@ import { useBrandConfig } from '@/lib/branding/branding'
import { isHosted } from '@/lib/core/config/feature-flags'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
const logger = createLogger('nav')
@@ -20,11 +21,12 @@ interface NavProps {
}
export default function Nav({ hideAuthButtons = false, variant = 'landing' }: NavProps = {}) {
const [githubStars, setGithubStars] = useState('25.1k')
const [githubStars, setGithubStars] = useState('25.8k')
const [isHovered, setIsHovered] = useState(false)
const [isLoginHovered, setIsLoginHovered] = useState(false)
const router = useRouter()
const brand = useBrandConfig()
const buttonClass = useBrandedButtonClass()
useEffect(() => {
if (variant !== 'landing') return
@@ -183,7 +185,7 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
href='/signup'
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className='group inline-flex items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[14px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all sm:text-[16px]'
className={`${buttonClass} group inline-flex items-center justify-center gap-2 rounded-[10px] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white transition-all`}
aria-label='Get started with Sim - Sign up for free'
prefetch={true}
>

View File

@@ -0,0 +1,27 @@
'use client'
import { useState } from 'react'
import { ArrowLeft, ChevronLeft } from 'lucide-react'
import Link from 'next/link'
export function BackLink() {
const [isHovered, setIsHovered] = useState(false)
return (
<Link
href='/studio'
className='group flex items-center gap-1 text-gray-600 text-sm hover:text-gray-900'
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<span className='group-hover:-translate-x-0.5 inline-flex transition-transform duration-200'>
{isHovered ? (
<ArrowLeft className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronLeft className='h-4 w-4' aria-hidden='true' />
)}
</span>
Back to Sim Studio
</Link>
)
}

View File

@@ -5,7 +5,10 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/emcn'
import { FAQ } from '@/lib/blog/faq'
import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/blog/registry'
import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildPostMetadata } from '@/lib/blog/seo'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BackLink } from '@/app/(landing)/studio/[slug]/back-link'
import { ShareButton } from '@/app/(landing)/studio/[slug]/share-button'
export async function generateStaticParams() {
const posts = await getAllPostMeta()
@@ -48,9 +51,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
/>
<header className='mx-auto max-w-[1450px] px-6 pt-8 sm:px-8 sm:pt-12 md:px-12 md:pt-16'>
<div className='mb-6'>
<Link href='/studio' className='text-gray-600 text-sm hover:text-gray-900'>
Back to Sim Studio
</Link>
<BackLink />
</div>
<div className='flex flex-col gap-8 md:flex-row md:gap-12'>
<div className='w-full flex-shrink-0 md:w-[450px]'>
@@ -75,28 +76,31 @@ export default async function Page({ params }: { params: Promise<{ slug: string
>
{post.title}
</h1>
<div className='mt-4 flex items-center gap-3'>
{(post.authors || [post.author]).map((a, idx) => (
<div key={idx} className='flex items-center gap-2'>
{a?.avatarUrl ? (
<Avatar className='size-6'>
<AvatarImage src={a.avatarUrl} alt={a.name} />
<AvatarFallback>{a.name.slice(0, 2)}</AvatarFallback>
</Avatar>
) : null}
<Link
href={a?.url || '#'}
target='_blank'
rel='noopener noreferrer author'
className='text-[14px] text-gray-600 leading-[1.5] hover:text-gray-900 sm:text-[16px]'
itemProp='author'
itemScope
itemType='https://schema.org/Person'
>
<span itemProp='name'>{a?.name}</span>
</Link>
</div>
))}
<div className='mt-4 flex items-center justify-between'>
<div className='flex items-center gap-3'>
{(post.authors || [post.author]).map((a, idx) => (
<div key={idx} className='flex items-center gap-2'>
{a?.avatarUrl ? (
<Avatar className='size-6'>
<AvatarImage src={a.avatarUrl} alt={a.name} />
<AvatarFallback>{a.name.slice(0, 2)}</AvatarFallback>
</Avatar>
) : null}
<Link
href={a?.url || '#'}
target='_blank'
rel='noopener noreferrer author'
className='text-[14px] text-gray-600 leading-[1.5] hover:text-gray-900 sm:text-[16px]'
itemProp='author'
itemScope
itemType='https://schema.org/Person'
>
<span itemProp='name'>{a?.name}</span>
</Link>
</div>
))}
</div>
<ShareButton url={`${getBaseUrl()}/studio/${slug}`} title={post.title} />
</div>
</div>
</div>

View File

@@ -0,0 +1,65 @@
'use client'
import { useState } from 'react'
import { Share2 } from 'lucide-react'
import { Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
interface ShareButtonProps {
url: string
title: string
}
export function ShareButton({ url, title }: ShareButtonProps) {
const [open, setOpen] = useState(false)
const [copied, setCopied] = useState(false)
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(url)
setCopied(true)
setTimeout(() => {
setCopied(false)
setOpen(false)
}, 1000)
} catch {
setOpen(false)
}
}
const handleShareTwitter = () => {
const tweetUrl = `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`
window.open(tweetUrl, '_blank', 'noopener,noreferrer')
setOpen(false)
}
const handleShareLinkedIn = () => {
const linkedInUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`
window.open(linkedInUrl, '_blank', 'noopener,noreferrer')
setOpen(false)
}
return (
<Popover
open={open}
onOpenChange={setOpen}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverTrigger asChild>
<button
className='flex items-center gap-1.5 text-gray-600 text-sm hover:text-gray-900'
aria-label='Share this post'
>
<Share2 className='h-4 w-4' />
<span>Share</span>
</button>
</PopoverTrigger>
<PopoverContent align='end' minWidth={140}>
<PopoverItem onClick={handleCopyLink}>{copied ? 'Copied!' : 'Copy link'}</PopoverItem>
<PopoverItem onClick={handleShareTwitter}>Share on X</PopoverItem>
<PopoverItem onClick={handleShareLinkedIn}>Share on LinkedIn</PopoverItem>
</PopoverContent>
</Popover>
)
}

View File

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

View File

@@ -15,7 +15,8 @@ const resetPasswordSchema = z.object({
.max(100, 'Password must not exceed 100 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number'),
.regex(/[0-9]/, 'Password must contain at least one number')
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'),
})
export async function POST(request: NextRequest) {

View File

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

View File

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

View File

@@ -6,10 +6,10 @@ import { executeInE2B } from '@/lib/execution/e2b'
import { executeInIsolatedVM } from '@/lib/execution/isolated-vm'
import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages'
import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants'
import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference'
import {
createEnvVarPattern,
createWorkflowVariablePattern,
resolveEnvVarReferences,
} from '@/executor/utils/reference-validation'
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
@@ -18,8 +18,8 @@ export const MAX_DURATION = 210
const logger = createLogger('FunctionExecuteAPI')
const E2B_JS_WRAPPER_LINES = 3 // Lines before user code: ';(async () => {', ' try {', ' const __sim_result = await (async () => {'
const E2B_PYTHON_WRAPPER_LINES = 1 // Lines before user code: 'def __sim_main__():'
const E2B_JS_WRAPPER_LINES = 3
const E2B_PYTHON_WRAPPER_LINES = 1
type TypeScriptModule = typeof import('typescript')
@@ -134,33 +134,21 @@ function extractEnhancedError(
if (error.stack) {
enhanced.stack = error.stack
// Parse stack trace to extract line and column information
// Handle both compilation errors and runtime errors
const stackLines: string[] = error.stack.split('\n')
for (const line of stackLines) {
// Pattern 1: Compilation errors - "user-function.js:6"
let match = line.match(/user-function\.js:(\d+)(?::(\d+))?/)
// Pattern 2: Runtime errors - "at user-function.js:5:12"
if (!match) {
match = line.match(/at\s+user-function\.js:(\d+):(\d+)/)
}
// Pattern 3: Generic patterns for any line containing our filename
if (!match) {
match = line.match(/user-function\.js:(\d+)(?::(\d+))?/)
}
if (match) {
const stackLine = Number.parseInt(match[1], 10)
const stackColumn = match[2] ? Number.parseInt(match[2], 10) : undefined
// Adjust line number to account for wrapper code
// The user code starts at a specific line in our wrapper
const adjustedLine = stackLine - userCodeStartLine + 1
// Check if this is a syntax error in wrapper code caused by incomplete user code
const isWrapperSyntaxError =
stackLine > userCodeStartLine &&
error.name === 'SyntaxError' &&
@@ -168,7 +156,6 @@ function extractEnhancedError(
error.message.includes('Unexpected end of input'))
if (isWrapperSyntaxError && userCode) {
// Map wrapper syntax errors to the last line of user code
const codeLines = userCode.split('\n')
const lastUserLine = codeLines.length
enhanced.line = lastUserLine
@@ -181,7 +168,6 @@ function extractEnhancedError(
enhanced.line = adjustedLine
enhanced.column = stackColumn
// Extract the actual line content from user code
if (userCode) {
const codeLines = userCode.split('\n')
if (adjustedLine <= codeLines.length) {
@@ -192,7 +178,6 @@ function extractEnhancedError(
}
if (stackLine <= userCodeStartLine) {
// Error is in wrapper code itself
enhanced.line = stackLine
enhanced.column = stackColumn
break
@@ -200,7 +185,6 @@ function extractEnhancedError(
}
}
// Clean up stack trace to show user-relevant information
const cleanedStackLines: string[] = stackLines
.filter(
(line: string) =>
@@ -214,9 +198,6 @@ function extractEnhancedError(
}
}
// Keep original message without adding error type prefix
// The error type will be added later in createUserFriendlyErrorMessage
return enhanced
}
@@ -231,7 +212,6 @@ function formatE2BError(
userCode: string,
prologueLineCount: number
): { formattedError: string; cleanedOutput: string } {
// Calculate line offset based on language and prologue
const wrapperLines =
language === CodeLanguage.Python ? E2B_PYTHON_WRAPPER_LINES : E2B_JS_WRAPPER_LINES
const totalOffset = prologueLineCount + wrapperLines
@@ -241,27 +221,20 @@ function formatE2BError(
let cleanErrorMsg = ''
if (language === CodeLanguage.Python) {
// Python error format: "Cell In[X], line Y" followed by error details
// Extract line number from the Cell reference
const cellMatch = errorOutput.match(/Cell In\[\d+\], line (\d+)/)
if (cellMatch) {
const originalLine = Number.parseInt(cellMatch[1], 10)
userLine = originalLine - totalOffset
}
// Extract clean error message from the error string
// Remove file references like "(detected at line X) (file.py, line Y)"
cleanErrorMsg = errorMessage
.replace(/\s*\(detected at line \d+\)/g, '')
.replace(/\s*\([^)]+\.py, line \d+\)/g, '')
.trim()
} else if (language === CodeLanguage.JavaScript) {
// JavaScript error format from E2B: "SyntaxError: /path/file.ts: Message. (line:col)\n\n 9 | ..."
// First, extract the error type and message from the first line
const firstLineEnd = errorMessage.indexOf('\n')
const firstLine = firstLineEnd > 0 ? errorMessage.substring(0, firstLineEnd) : errorMessage
// Parse: "SyntaxError: /home/user/index.ts: Missing semicolon. (11:9)"
const jsErrorMatch = firstLine.match(/^(\w+Error):\s*[^:]+:\s*([^(]+)\.\s*\((\d+):(\d+)\)/)
if (jsErrorMatch) {
cleanErrorType = jsErrorMatch[1]
@@ -269,13 +242,11 @@ function formatE2BError(
const originalLine = Number.parseInt(jsErrorMatch[3], 10)
userLine = originalLine - totalOffset
} else {
// Fallback: look for line number in the arrow pointer line (> 11 |)
const arrowMatch = errorMessage.match(/^>\s*(\d+)\s*\|/m)
if (arrowMatch) {
const originalLine = Number.parseInt(arrowMatch[1], 10)
userLine = originalLine - totalOffset
}
// Try to extract error type and message
const errorMatch = firstLine.match(/^(\w+Error):\s*(.+)/)
if (errorMatch) {
cleanErrorType = errorMatch[1]
@@ -289,13 +260,11 @@ function formatE2BError(
}
}
// Build the final clean error message
const finalErrorMsg =
cleanErrorType && cleanErrorMsg
? `${cleanErrorType}: ${cleanErrorMsg}`
: cleanErrorMsg || errorMessage
// Format with line number if available
let formattedError = finalErrorMsg
if (userLine && userLine > 0) {
const codeLines = userCode.split('\n')
@@ -311,7 +280,6 @@ function formatE2BError(
}
}
// For stdout, just return the clean error message without the full traceback
const cleanedOutput = finalErrorMsg
return { formattedError, cleanedOutput }
@@ -327,7 +295,6 @@ function createUserFriendlyErrorMessage(
): string {
let errorMessage = enhanced.message
// Add line information if available
if (enhanced.line !== undefined) {
let lineInfo = `Line ${enhanced.line}`
@@ -338,18 +305,14 @@ function createUserFriendlyErrorMessage(
errorMessage = `${lineInfo} - ${errorMessage}`
} else {
// If no line number, try to extract it from stack trace for display
if (enhanced.stack) {
const stackMatch = enhanced.stack.match(/user-function\.js:(\d+)(?::(\d+))?/)
if (stackMatch) {
const line = Number.parseInt(stackMatch[1], 10)
let lineInfo = `Line ${line}`
// Try to get line content if we have userCode
if (userCode) {
const codeLines = userCode.split('\n')
// Note: stackMatch gives us VM line number, need to adjust
// This is a fallback case, so we might not have perfect line mapping
if (line <= codeLines.length) {
const lineContent = codeLines[line - 1]?.trim()
if (lineContent) {
@@ -363,7 +326,6 @@ function createUserFriendlyErrorMessage(
}
}
// Add error type prefix with consistent naming
if (enhanced.name !== 'Error') {
const errorTypePrefix =
enhanced.name === 'SyntaxError'
@@ -374,7 +336,6 @@ function createUserFriendlyErrorMessage(
? 'Reference Error'
: enhanced.name
// Only add prefix if not already present
if (!errorMessage.toLowerCase().includes(errorTypePrefix.toLowerCase())) {
errorMessage = `${errorTypePrefix}: ${errorMessage}`
}
@@ -383,9 +344,6 @@ function createUserFriendlyErrorMessage(
return errorMessage
}
/**
* Resolves workflow variables with <variable.name> syntax
*/
function resolveWorkflowVariables(
code: string,
workflowVariables: Record<string, any>,
@@ -405,39 +363,35 @@ function resolveWorkflowVariables(
while ((match = regex.exec(code)) !== null) {
const variableName = match[1].trim()
// Find the variable by name (workflowVariables is indexed by ID, values are variable objects)
const foundVariable = Object.entries(workflowVariables).find(
([_, variable]) => normalizeName(variable.name || '') === variableName
)
let variableValue: unknown = ''
if (foundVariable) {
const variable = foundVariable[1]
variableValue = variable.value
if (!foundVariable) {
const availableVars = Object.values(workflowVariables)
.map((v) => v.name)
.filter(Boolean)
throw new Error(
`Variable "${variableName}" doesn't exist.` +
(availableVars.length > 0 ? ` Available: ${availableVars.join(', ')}` : '')
)
}
if (variable.value !== undefined && variable.value !== null) {
const variable = foundVariable[1]
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 {
// Handle 'string' type the same as 'plain' for backward compatibility
const type = variable.type === 'string' ? 'plain' : variable.type
// For plain text, use exactly what's entered without modifications
if (type === 'plain' && typeof variableValue === 'string') {
// Use as-is for plain text
} else if (type === 'number') {
variableValue = Number(variableValue)
} else if (type === 'boolean') {
variableValue = variableValue === 'true' || variableValue === true
} else if (type === 'json') {
try {
variableValue =
typeof variableValue === 'string' ? JSON.parse(variableValue) : variableValue
} catch {
// Keep original value if JSON parsing fails
}
}
variableValue = JSON.parse(variableValue)
} catch {
// Fallback to original value on error
variableValue = variable.value
// Keep as-is
}
}
}
@@ -450,11 +404,9 @@ function resolveWorkflowVariables(
})
}
// Process replacements in reverse order to maintain correct indices
for (let i = replacements.length - 1; i >= 0; i--) {
const { match: matchStr, index, variableName, variableValue } = replacements[i]
// Use variable reference approach
const safeVarName = `__variable_${variableName.replace(/[^a-zA-Z0-9_]/g, '_')}`
contextVariables[safeVarName] = variableValue
resolvedCode =
@@ -464,9 +416,6 @@ function resolveWorkflowVariables(
return resolvedCode
}
/**
* Resolves environment variables with {{var_name}} syntax
*/
function resolveEnvironmentVariables(
code: string,
params: Record<string, any>,
@@ -482,32 +431,28 @@ function resolveEnvironmentVariables(
const resolverVars: Record<string, string> = {}
Object.entries(params).forEach(([key, value]) => {
if (value) {
if (value !== undefined && value !== null) {
resolverVars[key] = String(value)
}
})
Object.entries(envVars).forEach(([key, value]) => {
if (value) {
if (value !== undefined && value !== null) {
resolverVars[key] = value
}
})
while ((match = regex.exec(code)) !== null) {
const varName = match[1].trim()
const resolved = resolveEnvVarReferences(match[0], resolverVars, {
allowEmbedded: true,
resolveExactMatch: true,
trimKeys: true,
onMissing: 'empty',
deep: false,
})
const varValue =
typeof resolved === 'string' ? resolved : resolved == null ? '' : String(resolved)
if (!(varName in resolverVars)) {
continue
}
replacements.push({
match: match[0],
index: match.index,
varName,
varValue: String(varValue),
varValue: resolverVars[varName],
})
}
@@ -523,64 +468,59 @@ function resolveEnvironmentVariables(
return resolvedCode
}
/**
* Resolves tags with <tag_name> syntax (including nested paths like <block.response.data>)
*/
function resolveTagVariables(
code: string,
params: Record<string, any>,
blockData: Record<string, any>,
blockData: Record<string, unknown>,
blockNameMapping: Record<string, string>,
contextVariables: Record<string, any>
blockOutputSchemas: Record<string, OutputSchema>,
contextVariables: Record<string, unknown>,
language = 'javascript'
): string {
let resolvedCode = code
const undefinedLiteral = language === 'python' ? 'None' : 'undefined'
const tagPattern = new RegExp(
`${REFERENCE.START}([a-zA-Z_][a-zA-Z0-9_${REFERENCE.PATH_DELIMITER}]*[a-zA-Z0-9_])${REFERENCE.END}`,
`${REFERENCE.START}([a-zA-Z_](?:[a-zA-Z0-9_${REFERENCE.PATH_DELIMITER}]*[a-zA-Z0-9_])?)${REFERENCE.END}`,
'g'
)
const tagMatches = resolvedCode.match(tagPattern) || []
for (const match of tagMatches) {
const tagName = match.slice(REFERENCE.START.length, -REFERENCE.END.length).trim()
const pathParts = tagName.split(REFERENCE.PATH_DELIMITER)
const blockName = pathParts[0]
const fieldPath = pathParts.slice(1)
// Handle nested paths like "getrecord.response.data" or "function1.response.result"
// First try params, then blockData directly, then try with block name mapping
let tagValue = getNestedValue(params, tagName) || getNestedValue(blockData, tagName) || ''
const result = resolveBlockReference(blockName, fieldPath, {
blockNameMapping,
blockData,
blockOutputSchemas,
})
// If not found and the path starts with a block name, try mapping the block name to ID
if (!tagValue && tagName.includes(REFERENCE.PATH_DELIMITER)) {
const pathParts = tagName.split(REFERENCE.PATH_DELIMITER)
const normalizedBlockName = pathParts[0] // This should already be normalized like "function1"
if (!result) {
continue
}
// Direct lookup using normalized block name
const blockId = blockNameMapping[normalizedBlockName] ?? null
let tagValue = result.value
if (blockId) {
const remainingPath = pathParts.slice(1).join('.')
const fullPath = `${blockId}.${remainingPath}`
tagValue = getNestedValue(blockData, fullPath) || ''
if (tagValue === undefined) {
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), undefinedLiteral)
continue
}
if (typeof tagValue === 'string') {
const trimmed = tagValue.trimStart()
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
tagValue = JSON.parse(tagValue)
} catch {
// Keep as string if not valid JSON
}
}
}
// If the value is a stringified JSON, parse it back to object
if (
typeof tagValue === 'string' &&
tagValue.length > 100 &&
(tagValue.startsWith('{') || tagValue.startsWith('['))
) {
try {
tagValue = JSON.parse(tagValue)
} catch (e) {
// 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(/_/g, '_1').replace(/\./g, '_0')}`
contextVariables[safeVarName] = tagValue
// Replace the template with a variable reference
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), safeVarName)
}
@@ -596,44 +536,31 @@ function resolveTagVariables(
*/
function resolveCodeVariables(
code: string,
params: Record<string, any>,
params: Record<string, unknown>,
envVars: Record<string, string> = {},
blockData: Record<string, any> = {},
blockData: Record<string, unknown> = {},
blockNameMapping: Record<string, string> = {},
workflowVariables: Record<string, any> = {}
): { resolvedCode: string; contextVariables: Record<string, any> } {
blockOutputSchemas: Record<string, OutputSchema> = {},
workflowVariables: Record<string, unknown> = {},
language = 'javascript'
): { resolvedCode: string; contextVariables: Record<string, unknown> } {
let resolvedCode = code
const contextVariables: Record<string, any> = {}
const contextVariables: Record<string, unknown> = {}
// Resolve workflow variables with <variable.name> syntax first
resolvedCode = resolveWorkflowVariables(resolvedCode, workflowVariables, contextVariables)
// Resolve environment variables with {{var_name}} syntax
resolvedCode = resolveEnvironmentVariables(resolvedCode, params, envVars, contextVariables)
// Resolve tags with <tag_name> syntax (including nested paths like <block.response.data>)
resolvedCode = resolveTagVariables(
resolvedCode,
params,
blockData,
blockNameMapping,
contextVariables
blockOutputSchemas,
contextVariables,
language
)
return { resolvedCode, contextVariables }
}
/**
* Get nested value from object using dot notation path
*/
function getNestedValue(obj: any, path: string): any {
if (!obj || !path) return undefined
return path.split('.').reduce((current, key) => {
return current && typeof current === 'object' ? current[key] : undefined
}, obj)
}
/**
* Remove one trailing newline from stdout
* This handles the common case where print() or console.log() adds a trailing \n
@@ -666,12 +593,12 @@ export async function POST(req: NextRequest) {
envVars = {},
blockData = {},
blockNameMapping = {},
blockOutputSchemas = {},
workflowVariables = {},
workflowId,
isCustomTool = false,
} = body
// Extract internal parameters that shouldn't be passed to the execution context
const executionParams = { ...params }
executionParams._context = undefined
@@ -683,21 +610,21 @@ export async function POST(req: NextRequest) {
isCustomTool,
})
// Resolve variables in the code with workflow environment variables
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
const codeResolution = resolveCodeVariables(
code,
executionParams,
envVars,
blockData,
blockNameMapping,
workflowVariables
blockOutputSchemas,
workflowVariables,
lang
)
resolvedCode = codeResolution.resolvedCode
const contextVariables = codeResolution.contextVariables
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
// Extract imports once for JavaScript code (reuse later to avoid double extraction)
let jsImports = ''
let jsRemainingCode = resolvedCode
let hasImports = false
@@ -707,31 +634,22 @@ export async function POST(req: NextRequest) {
jsImports = extractionResult.imports
jsRemainingCode = extractionResult.remainingCode
// Check for ES6 imports or CommonJS require statements
// ES6 imports are extracted by the TypeScript parser
// Also check for require() calls which indicate external dependencies
const hasRequireStatements = /require\s*\(\s*['"`]/.test(resolvedCode)
hasImports = jsImports.trim().length > 0 || hasRequireStatements
}
// Python always requires E2B
if (lang === CodeLanguage.Python && !isE2bEnabled) {
throw new Error(
'Python execution requires E2B to be enabled. Please contact your administrator to enable E2B, or use JavaScript instead.'
)
}
// JavaScript with imports requires E2B
if (lang === CodeLanguage.JavaScript && hasImports && !isE2bEnabled) {
throw new Error(
'JavaScript code with import statements requires E2B to be enabled. Please remove the import statements, or contact your administrator to enable E2B.'
)
}
// Use E2B if:
// - E2B is enabled AND
// - Not a custom tool AND
// - (Python OR JavaScript with imports)
const useE2B =
isE2bEnabled &&
!isCustomTool &&
@@ -744,13 +662,10 @@ export async function POST(req: NextRequest) {
language: lang,
})
let prologue = ''
const epilogue = ''
if (lang === CodeLanguage.JavaScript) {
// Track prologue lines for error adjustment
let prologueLineCount = 0
// Reuse the imports we already extracted earlier
const imports = jsImports
const remainingCode = jsRemainingCode
@@ -765,7 +680,11 @@ export async function POST(req: NextRequest) {
prologue += `const environmentVariables = JSON.parse(${JSON.stringify(JSON.stringify(envVars))});\n`
prologueLineCount++
for (const [k, v] of Object.entries(contextVariables)) {
prologue += `const ${k} = JSON.parse(${JSON.stringify(JSON.stringify(v))});\n`
if (v === undefined) {
prologue += `const ${k} = undefined;\n`
} else {
prologue += `const ${k} = JSON.parse(${JSON.stringify(JSON.stringify(v))});\n`
}
prologueLineCount++
}
@@ -782,7 +701,7 @@ export async function POST(req: NextRequest) {
' }',
'})();',
].join('\n')
const codeForE2B = importSection + prologue + wrapped + epilogue
const codeForE2B = importSection + prologue + wrapped
const execStart = Date.now()
const {
@@ -804,7 +723,6 @@ export async function POST(req: NextRequest) {
error: e2bError,
})
// If there was an execution error, format it properly
if (e2bError) {
const { formattedError, cleanedOutput } = formatE2BError(
e2bError,
@@ -828,7 +746,7 @@ export async function POST(req: NextRequest) {
output: { result: e2bResult ?? null, stdout: cleanStdout(stdout), executionTime },
})
}
// Track prologue lines for error adjustment
let prologueLineCount = 0
prologue += 'import json\n'
prologueLineCount++
@@ -837,7 +755,11 @@ export async function POST(req: NextRequest) {
prologue += `environmentVariables = json.loads(${JSON.stringify(JSON.stringify(envVars))})\n`
prologueLineCount++
for (const [k, v] of Object.entries(contextVariables)) {
prologue += `${k} = json.loads(${JSON.stringify(JSON.stringify(v))})\n`
if (v === undefined) {
prologue += `${k} = None\n`
} else {
prologue += `${k} = json.loads(${JSON.stringify(JSON.stringify(v))})\n`
}
prologueLineCount++
}
const wrapped = [
@@ -846,7 +768,7 @@ export async function POST(req: NextRequest) {
'__sim_result__ = __sim_main__()',
"print('__SIM_RESULT__=' + json.dumps(__sim_result__))",
].join('\n')
const codeForE2B = prologue + wrapped + epilogue
const codeForE2B = prologue + wrapped
const execStart = Date.now()
const {
@@ -868,7 +790,6 @@ export async function POST(req: NextRequest) {
error: e2bError,
})
// If there was an execution error, format it properly
if (e2bError) {
const { formattedError, cleanedOutput } = formatE2BError(
e2bError,
@@ -897,7 +818,6 @@ export async function POST(req: NextRequest) {
const wrapperLines = ['(async () => {', ' try {']
if (isCustomTool) {
wrapperLines.push(' // For custom tools, make parameters directly accessible')
Object.keys(executionParams).forEach((key) => {
wrapperLines.push(` const ${key} = params.${key};`)
})
@@ -931,12 +851,10 @@ export async function POST(req: NextRequest) {
})
const ivmError = isolatedResult.error
// Adjust line number for prepended param destructuring in custom tools
let adjustedLine = ivmError.line
let adjustedLineContent = ivmError.lineContent
if (prependedLineCount > 0 && ivmError.line !== undefined) {
adjustedLine = Math.max(1, ivmError.line - prependedLineCount)
// Get line content from original user code, not the prepended code
const codeLines = resolvedCode.split('\n')
if (adjustedLine <= codeLines.length) {
adjustedLineContent = codeLines[adjustedLine - 1]?.trim()

View File

@@ -157,7 +157,7 @@ describe('Knowledge Base Documents API Route', () => {
expect(vi.mocked(getDocuments)).toHaveBeenCalledWith(
'kb-123',
{
includeDisabled: false,
enabledFilter: undefined,
search: undefined,
limit: 50,
offset: 0,
@@ -166,7 +166,7 @@ describe('Knowledge Base Documents API Route', () => {
)
})
it('should filter disabled documents by default', async () => {
it('should return documents with default filter', async () => {
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
const { getDocuments } = await import('@/lib/knowledge/documents/service')
@@ -194,7 +194,7 @@ describe('Knowledge Base Documents API Route', () => {
expect(vi.mocked(getDocuments)).toHaveBeenCalledWith(
'kb-123',
{
includeDisabled: false,
enabledFilter: undefined,
search: undefined,
limit: 50,
offset: 0,
@@ -203,7 +203,7 @@ describe('Knowledge Base Documents API Route', () => {
)
})
it('should include disabled documents when requested', async () => {
it('should filter documents by enabled status when requested', async () => {
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
const { getDocuments } = await import('@/lib/knowledge/documents/service')
@@ -223,7 +223,7 @@ describe('Knowledge Base Documents API Route', () => {
},
})
const url = 'http://localhost:3000/api/knowledge/kb-123/documents?includeDisabled=true'
const url = 'http://localhost:3000/api/knowledge/kb-123/documents?enabledFilter=disabled'
const req = new Request(url, { method: 'GET' }) as any
const { GET } = await import('@/app/api/knowledge/[id]/documents/route')
@@ -233,7 +233,7 @@ describe('Knowledge Base Documents API Route', () => {
expect(vi.mocked(getDocuments)).toHaveBeenCalledWith(
'kb-123',
{
includeDisabled: true,
enabledFilter: 'disabled',
search: undefined,
limit: 50,
offset: 0,
@@ -361,8 +361,7 @@ describe('Knowledge Base Documents API Route', () => {
expect(vi.mocked(createSingleDocument)).toHaveBeenCalledWith(
validDocumentData,
'kb-123',
expect.any(String),
'user-123'
expect.any(String)
)
})
@@ -470,8 +469,7 @@ describe('Knowledge Base Documents API Route', () => {
expect(vi.mocked(createDocumentRecords)).toHaveBeenCalledWith(
validBulkData.documents,
'kb-123',
expect.any(String),
'user-123'
expect.any(String)
)
expect(vi.mocked(processDocumentsWithQueue)).toHaveBeenCalled()
})

View File

@@ -5,6 +5,7 @@ import { z } from 'zod'
import { getSession } from '@/lib/auth'
import {
bulkDocumentOperation,
bulkDocumentOperationByFilter,
createDocumentRecords,
createSingleDocument,
getDocuments,
@@ -57,13 +58,20 @@ const BulkCreateDocumentsSchema = z.object({
bulk: z.literal(true),
})
const BulkUpdateDocumentsSchema = z.object({
operation: z.enum(['enable', 'disable', 'delete']),
documentIds: z
.array(z.string())
.min(1, 'At least one document ID is required')
.max(100, 'Cannot operate on more than 100 documents at once'),
})
const BulkUpdateDocumentsSchema = z
.object({
operation: z.enum(['enable', 'disable', 'delete']),
documentIds: z
.array(z.string())
.min(1, 'At least one document ID is required')
.max(100, 'Cannot operate on more than 100 documents at once')
.optional(),
selectAll: z.boolean().optional(),
enabledFilter: z.enum(['all', 'enabled', 'disabled']).optional(),
})
.refine((data) => data.selectAll || (data.documentIds && data.documentIds.length > 0), {
message: 'Either selectAll must be true or documentIds must be provided',
})
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = randomUUID().slice(0, 8)
@@ -90,14 +98,17 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
}
const url = new URL(req.url)
const includeDisabled = url.searchParams.get('includeDisabled') === 'true'
const enabledFilter = url.searchParams.get('enabledFilter') as
| 'all'
| 'enabled'
| 'disabled'
| null
const search = url.searchParams.get('search') || undefined
const limit = Number.parseInt(url.searchParams.get('limit') || '50')
const offset = Number.parseInt(url.searchParams.get('offset') || '0')
const sortByParam = url.searchParams.get('sortBy')
const sortOrderParam = url.searchParams.get('sortOrder')
// Validate sort parameters
const validSortFields: DocumentSortField[] = [
'filename',
'fileSize',
@@ -105,6 +116,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
'chunkCount',
'uploadedAt',
'processingStatus',
'enabled',
]
const validSortOrders: SortOrder[] = ['asc', 'desc']
@@ -120,7 +132,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
const result = await getDocuments(
knowledgeBaseId,
{
includeDisabled,
enabledFilter: enabledFilter || undefined,
search,
limit,
offset,
@@ -190,8 +202,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const createdDocuments = await createDocumentRecords(
validatedData.documents,
knowledgeBaseId,
requestId,
userId
requestId
)
logger.info(
@@ -250,16 +261,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
throw validationError
}
} else {
// Handle single document creation
try {
const validatedData = CreateDocumentSchema.parse(body)
const newDocument = await createSingleDocument(
validatedData,
knowledgeBaseId,
requestId,
userId
)
const newDocument = await createSingleDocument(validatedData, knowledgeBaseId, requestId)
try {
const { PlatformEvents } = await import('@/lib/core/telemetry')
@@ -294,7 +299,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
} catch (error) {
logger.error(`[${requestId}] Error creating document`, error)
// Check if it's a storage limit error
const errorMessage = error instanceof Error ? error.message : 'Failed to create document'
const isStorageLimitError =
errorMessage.includes('Storage limit exceeded') || errorMessage.includes('storage limit')
@@ -331,16 +335,22 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
try {
const validatedData = BulkUpdateDocumentsSchema.parse(body)
const { operation, documentIds } = validatedData
const { operation, documentIds, selectAll, enabledFilter } = validatedData
try {
const result = await bulkDocumentOperation(
knowledgeBaseId,
operation,
documentIds,
requestId,
session.user.id
)
let result
if (selectAll) {
result = await bulkDocumentOperationByFilter(
knowledgeBaseId,
operation,
enabledFilter,
requestId
)
} else if (documentIds && documentIds.length > 0) {
result = await bulkDocumentOperation(knowledgeBaseId, operation, documentIds, requestId)
} else {
return NextResponse.json({ error: 'No documents specified' }, { status: 400 })
}
return NextResponse.json({
success: true,

View File

@@ -0,0 +1,203 @@
/**
* POST /api/v1/admin/credits
*
* Issue credits to a user by user ID or email.
*
* Body:
* - userId?: string - The user ID to issue credits to
* - email?: string - The user email to issue credits to (alternative to userId)
* - amount: number - The amount of credits to issue (in dollars)
* - reason?: string - Reason for issuing credits (for audit logging)
*
* Response: AdminSingleResponse<{
* success: true,
* entityType: 'user' | 'organization',
* entityId: string,
* amount: number,
* newCreditBalance: number,
* newUsageLimit: number,
* }>
*
* For Pro users: credits are added to user_stats.credit_balance
* For Team users: credits are added to organization.credit_balance
* Usage limits are updated accordingly to allow spending the credits.
*/
import { db } from '@sim/db'
import { organization, subscription, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { addCredits } from '@/lib/billing/credits/balance'
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
const logger = createLogger('AdminCreditsAPI')
export const POST = withAdminAuth(async (request) => {
try {
const body = await request.json()
const { userId, email, amount, reason } = body
if (!userId && !email) {
return badRequestResponse('Either userId or email is required')
}
if (typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) {
return badRequestResponse('amount must be a positive number')
}
let resolvedUserId: string
let userEmail: string | null = null
if (userId) {
const [userData] = await db
.select({ id: user.id, email: user.email })
.from(user)
.where(eq(user.id, userId))
.limit(1)
if (!userData) {
return notFoundResponse('User')
}
resolvedUserId = userData.id
userEmail = userData.email
} else {
const normalizedEmail = email.toLowerCase().trim()
const [userData] = await db
.select({ id: user.id, email: user.email })
.from(user)
.where(eq(user.email, normalizedEmail))
.limit(1)
if (!userData) {
return notFoundResponse('User with email')
}
resolvedUserId = userData.id
userEmail = userData.email
}
const userSubscription = await getHighestPrioritySubscription(resolvedUserId)
if (!userSubscription || !['pro', 'team', 'enterprise'].includes(userSubscription.plan)) {
return badRequestResponse(
'User must have an active Pro, Team, or Enterprise subscription to receive credits'
)
}
let entityType: 'user' | 'organization'
let entityId: string
const plan = userSubscription.plan
let seats: number | null = null
if (plan === 'team' || plan === 'enterprise') {
entityType = 'organization'
entityId = userSubscription.referenceId
const [orgExists] = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, entityId))
.limit(1)
if (!orgExists) {
return notFoundResponse('Organization')
}
const [subData] = await db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, entityId), eq(subscription.status, 'active')))
.limit(1)
seats = getEffectiveSeats(subData)
} else {
entityType = 'user'
entityId = resolvedUserId
const [existingStats] = await db
.select({ id: userStats.id })
.from(userStats)
.where(eq(userStats.userId, entityId))
.limit(1)
if (!existingStats) {
await db.insert(userStats).values({
id: nanoid(),
userId: entityId,
})
}
}
await addCredits(entityType, entityId, amount)
let newCreditBalance: number
if (entityType === 'organization') {
const [orgData] = await db
.select({ creditBalance: organization.creditBalance })
.from(organization)
.where(eq(organization.id, entityId))
.limit(1)
newCreditBalance = Number.parseFloat(orgData?.creditBalance || '0')
} else {
const [stats] = await db
.select({ creditBalance: userStats.creditBalance })
.from(userStats)
.where(eq(userStats.userId, entityId))
.limit(1)
newCreditBalance = Number.parseFloat(stats?.creditBalance || '0')
}
await setUsageLimitForCredits(entityType, entityId, plan, seats, newCreditBalance)
let newUsageLimit: number
if (entityType === 'organization') {
const [orgData] = await db
.select({ orgUsageLimit: organization.orgUsageLimit })
.from(organization)
.where(eq(organization.id, entityId))
.limit(1)
newUsageLimit = Number.parseFloat(orgData?.orgUsageLimit || '0')
} else {
const [stats] = await db
.select({ currentUsageLimit: userStats.currentUsageLimit })
.from(userStats)
.where(eq(userStats.userId, entityId))
.limit(1)
newUsageLimit = Number.parseFloat(stats?.currentUsageLimit || '0')
}
logger.info('Admin API: Issued credits', {
resolvedUserId,
userEmail,
entityType,
entityId,
amount,
newCreditBalance,
newUsageLimit,
reason: reason || 'No reason provided',
})
return singleResponse({
success: true,
userId: resolvedUserId,
userEmail,
entityType,
entityId,
amount,
newCreditBalance,
newUsageLimit,
})
} catch (error) {
logger.error('Admin API: Failed to issue credits', { error })
return internalErrorResponse('Failed to issue credits')
}
})

View File

@@ -63,6 +63,9 @@
* GET /api/v1/admin/subscriptions/:id - Get subscription details
* DELETE /api/v1/admin/subscriptions/:id - Cancel subscription (?atPeriodEnd=true for scheduled)
*
* Credits:
* POST /api/v1/admin/credits - Issue credits to user (by userId or email)
*
* Access Control (Permission Groups):
* GET /api/v1/admin/access-control - List permission groups (?organizationId=X)
* DELETE /api/v1/admin/access-control - Delete permission groups for org (?organizationId=X)

View File

@@ -0,0 +1,27 @@
'use client'
import Link from 'next/link'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
interface BrandedLinkProps {
href: string
children: React.ReactNode
className?: string
target?: string
rel?: string
}
export function BrandedLink({ href, children, className = '', target, rel }: BrandedLinkProps) {
const buttonClass = useBrandedButtonClass()
return (
<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,6 +2,7 @@ import { BookOpen, Github, Rss } from 'lucide-react'
import Link from 'next/link'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BrandedLink } from '@/app/changelog/components/branded-link'
import ChangelogList from '@/app/changelog/components/timeline-list'
export interface ChangelogEntry {
@@ -66,25 +67,24 @@ export default async function ChangelogContent() {
<hr className='mt-6 border-border' />
<div className='mt-6 flex flex-wrap items-center gap-3 text-sm'>
<Link
<BrandedLink
href='https://github.com/simstudioai/sim/releases'
target='_blank'
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' />
View on GitHub
</Link>
</BrandedLink>
<Link
href='https://docs.sim.ai'
className='inline-flex items-center gap-2 rounded-md border border-border px-3 py-1.5 hover:bg-muted'
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'
>
<BookOpen className='h-4 w-4' />
Documentation
</Link>
<Link
href='/changelog.xml'
className='inline-flex items-center gap-2 rounded-md border border-border px-3 py-1.5 hover:bg-muted'
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'
>
<Rss className='h-4 w-4' />
RSS Feed

View File

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

View File

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

View File

@@ -61,6 +61,7 @@ export function EditChunkModal({
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
const [pendingNavigation, setPendingNavigation] = useState<(() => void) | null>(null)
const [tokenizerOn, setTokenizerOn] = useState(false)
const [hoveredTokenIndex, setHoveredTokenIndex] = useState<number | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const error = mutationError?.message ?? null
@@ -254,6 +255,8 @@ export function EditChunkModal({
style={{
backgroundColor: getTokenBgColor(index),
}}
onMouseEnter={() => setHoveredTokenIndex(index)}
onMouseLeave={() => setHoveredTokenIndex(null)}
>
{token}
</span>
@@ -281,6 +284,11 @@ export function EditChunkModal({
<div className='flex items-center gap-[8px]'>
<span className='text-[12px] text-[var(--text-secondary)]'>Tokenizer</span>
<Switch checked={tokenizerOn} onCheckedChange={setTokenizerOn} />
{tokenizerOn && hoveredTokenIndex !== null && (
<span className='text-[12px] text-[var(--text-tertiary)]'>
Token #{hoveredTokenIndex + 1}
</span>
)}
</div>
<span className='text-[12px] text-[var(--text-secondary)]'>
{tokenCount.toLocaleString()}

View File

@@ -36,6 +36,7 @@ import {
import { Input } from '@/components/ui/input'
import { SearchHighlight } from '@/components/ui/search-highlight'
import { Skeleton } from '@/components/ui/skeleton'
import { formatAbsoluteDate, formatRelativeTime } from '@/lib/core/utils/formatting'
import type { ChunkData } from '@/lib/knowledge/types'
import {
ChunkContextMenu,
@@ -58,55 +59,6 @@ import {
const logger = createLogger('Document')
/**
* Formats a date string to relative time (e.g., "2h ago", "3d ago")
*/
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffInSeconds < 60) {
return 'just now'
}
if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60)
return `${minutes}m ago`
}
if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600)
return `${hours}h ago`
}
if (diffInSeconds < 604800) {
const days = Math.floor(diffInSeconds / 86400)
return `${days}d ago`
}
if (diffInSeconds < 2592000) {
const weeks = Math.floor(diffInSeconds / 604800)
return `${weeks}w ago`
}
if (diffInSeconds < 31536000) {
const months = Math.floor(diffInSeconds / 2592000)
return `${months}mo ago`
}
const years = Math.floor(diffInSeconds / 31536000)
return `${years}y ago`
}
/**
* Formats a date string to absolute format for tooltip display
*/
function formatAbsoluteDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
interface DocumentProps {
knowledgeBaseId: string
documentId: string
@@ -304,7 +256,6 @@ export function Document({
const [searchQuery, setSearchQuery] = useState('')
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('')
const [isSearching, setIsSearching] = useState(false)
const {
chunks: initialChunks,
@@ -344,7 +295,6 @@ export function Document({
const handler = setTimeout(() => {
startTransition(() => {
setDebouncedSearchQuery(searchQuery)
setIsSearching(searchQuery.trim().length > 0)
})
}, 200)
@@ -353,6 +303,7 @@ export function Document({
}
}, [searchQuery])
const isSearching = debouncedSearchQuery.trim().length > 0
const showingSearch = isSearching && searchQuery.trim().length > 0 && searchResults.length > 0
const SEARCH_PAGE_SIZE = 50
const maxSearchPages = Math.ceil(searchResults.length / SEARCH_PAGE_SIZE)

View File

@@ -27,6 +27,10 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
Table,
TableBody,
TableCell,
@@ -40,8 +44,11 @@ import { Input } from '@/components/ui/input'
import { SearchHighlight } from '@/components/ui/search-highlight'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/core/utils/cn'
import { formatAbsoluteDate, formatRelativeTime } from '@/lib/core/utils/formatting'
import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowledge/constants'
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
import type { DocumentData } from '@/lib/knowledge/types'
import { formatFileSize } from '@/lib/uploads/utils/file-utils'
import {
ActionBar,
AddDocumentsModal,
@@ -189,8 +196,8 @@ function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoadingProps)
</div>
</div>
<div className='mt-[4px]'>
<Skeleton className='h-[21px] w-[300px] rounded-[4px]' />
<div>
<Skeleton className='mt-[4px] h-[21px] w-[300px] rounded-[4px]' />
</div>
<div className='mt-[16px] flex items-center gap-[8px]'>
@@ -208,9 +215,12 @@ function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoadingProps)
className='flex-1 border-0 bg-transparent px-0 font-medium text-[var(--text-secondary)] text-small leading-none placeholder:text-[var(--text-subtle)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<Button disabled variant='tertiary' className='h-[32px] rounded-[6px]'>
Add Documents
</Button>
<div className='flex items-center gap-[8px]'>
<Skeleton className='h-[32px] w-[52px] rounded-[6px]' />
<Button disabled variant='tertiary' className='h-[32px] rounded-[6px]'>
Add Documents
</Button>
</div>
</div>
<div className='mt-[12px] flex flex-1 flex-col overflow-hidden'>
@@ -222,73 +232,11 @@ function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoadingProps)
)
}
/**
* Formats a date string to relative time (e.g., "2h ago", "3d ago")
*/
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffInSeconds < 60) {
return 'just now'
}
if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60)
return `${minutes}m ago`
}
if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600)
return `${hours}h ago`
}
if (diffInSeconds < 604800) {
const days = Math.floor(diffInSeconds / 86400)
return `${days}d ago`
}
if (diffInSeconds < 2592000) {
const weeks = Math.floor(diffInSeconds / 604800)
return `${weeks}w ago`
}
if (diffInSeconds < 31536000) {
const months = Math.floor(diffInSeconds / 2592000)
return `${months}mo ago`
}
const years = Math.floor(diffInSeconds / 31536000)
return `${years}y ago`
}
/**
* Formats a date string to absolute format for tooltip display
*/
function formatAbsoluteDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
interface KnowledgeBaseProps {
id: string
knowledgeBaseName?: string
}
function getFileIcon(mimeType: string, filename: string) {
const IconComponent = getDocumentIcon(mimeType, filename)
return <IconComponent className='h-6 w-5 flex-shrink-0' />
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
}
const AnimatedLoader = ({ className }: { className?: string }) => (
<Loader2 className={cn(className, 'animate-spin')} />
)
@@ -336,53 +284,24 @@ const getStatusBadge = (doc: DocumentData) => {
}
}
const TAG_SLOTS = [
'tag1',
'tag2',
'tag3',
'tag4',
'tag5',
'tag6',
'tag7',
'number1',
'number2',
'number3',
'number4',
'number5',
'date1',
'date2',
'boolean1',
'boolean2',
'boolean3',
] as const
type TagSlot = (typeof TAG_SLOTS)[number]
interface TagValue {
slot: TagSlot
slot: AllTagSlot
displayName: string
value: string
}
const TAG_FIELD_TYPES: Record<string, string> = {
tag: 'text',
number: 'number',
date: 'date',
boolean: 'boolean',
}
/**
* Computes tag values for a document
*/
function getDocumentTags(doc: DocumentData, definitions: TagDefinition[]): TagValue[] {
const result: TagValue[] = []
for (const slot of TAG_SLOTS) {
for (const slot of ALL_TAG_SLOTS) {
const raw = doc[slot]
if (raw == null) continue
const def = definitions.find((d) => d.tagSlot === slot)
const fieldType = def?.fieldType || TAG_FIELD_TYPES[slot.replace(/\d+$/, '')] || 'text'
const fieldType = def?.fieldType || getFieldTypeForSlot(slot) || 'text'
let value: string
if (fieldType === 'date') {
@@ -424,6 +343,8 @@ export function KnowledgeBase({
const [searchQuery, setSearchQuery] = useState('')
const [showTagsModal, setShowTagsModal] = useState(false)
const [enabledFilter, setEnabledFilter] = useState<'all' | 'enabled' | 'disabled'>('all')
const [isFilterPopoverOpen, setIsFilterPopoverOpen] = useState(false)
/**
* Memoize the search query setter to prevent unnecessary re-renders
@@ -434,6 +355,7 @@ export function KnowledgeBase({
}, [])
const [selectedDocuments, setSelectedDocuments] = useState<Set<string>>(new Set())
const [isSelectAllMode, setIsSelectAllMode] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [showAddDocumentsModal, setShowAddDocumentsModal] = useState(false)
const [showDeleteDocumentModal, setShowDeleteDocumentModal] = useState(false)
@@ -460,7 +382,6 @@ export function KnowledgeBase({
error: knowledgeBaseError,
refresh: refreshKnowledgeBase,
} = useKnowledgeBase(id)
const [hasProcessingDocuments, setHasProcessingDocuments] = useState(false)
const {
documents,
@@ -469,6 +390,7 @@ export function KnowledgeBase({
isFetching: isFetchingDocuments,
isPlaceholderData: isPlaceholderDocuments,
error: documentsError,
hasProcessingDocuments,
updateDocument,
refreshDocuments,
} = useKnowledgeBaseDocuments(id, {
@@ -477,7 +399,14 @@ export function KnowledgeBase({
offset: (currentPage - 1) * DOCUMENTS_PER_PAGE,
sortBy,
sortOrder,
refetchInterval: hasProcessingDocuments && !isDeleting ? 3000 : false,
refetchInterval: (data) => {
if (isDeleting) return false
const hasPending = data?.documents?.some(
(doc) => doc.processingStatus === 'pending' || doc.processingStatus === 'processing'
)
return hasPending ? 3000 : false
},
enabledFilter,
})
const { tagDefinitions } = useKnowledgeBaseTagDefinitions(id)
@@ -543,52 +472,52 @@ export function KnowledgeBase({
</TableHead>
)
useEffect(() => {
const processing = documents.some(
(doc) => doc.processingStatus === 'pending' || doc.processingStatus === 'processing'
)
setHasProcessingDocuments(processing)
if (processing) {
checkForDeadProcesses()
}
}, [documents])
/**
* Checks for documents with stale processing states and marks them as failed
*/
const checkForDeadProcesses = () => {
const now = new Date()
const DEAD_PROCESS_THRESHOLD_MS = 600 * 1000 // 10 minutes
const checkForDeadProcesses = useCallback(
(docsToCheck: DocumentData[]) => {
const now = new Date()
const DEAD_PROCESS_THRESHOLD_MS = 600 * 1000 // 10 minutes
const staleDocuments = documents.filter((doc) => {
if (doc.processingStatus !== 'processing' || !doc.processingStartedAt) {
return false
}
const processingDuration = now.getTime() - new Date(doc.processingStartedAt).getTime()
return processingDuration > DEAD_PROCESS_THRESHOLD_MS
})
if (staleDocuments.length === 0) return
logger.warn(`Found ${staleDocuments.length} documents with dead processes`)
staleDocuments.forEach((doc) => {
updateDocumentMutation(
{
knowledgeBaseId: id,
documentId: doc.id,
updates: { markFailedDueToTimeout: true },
},
{
onSuccess: () => {
logger.info(`Successfully marked dead process as failed for document: ${doc.filename}`)
},
const staleDocuments = docsToCheck.filter((doc) => {
if (doc.processingStatus !== 'processing' || !doc.processingStartedAt) {
return false
}
)
})
}
const processingDuration = now.getTime() - new Date(doc.processingStartedAt).getTime()
return processingDuration > DEAD_PROCESS_THRESHOLD_MS
})
if (staleDocuments.length === 0) return
logger.warn(`Found ${staleDocuments.length} documents with dead processes`)
staleDocuments.forEach((doc) => {
updateDocumentMutation(
{
knowledgeBaseId: id,
documentId: doc.id,
updates: { markFailedDueToTimeout: true },
},
{
onSuccess: () => {
logger.info(
`Successfully marked dead process as failed for document: ${doc.filename}`
)
},
}
)
})
},
[id, updateDocumentMutation]
)
useEffect(() => {
if (hasProcessingDocuments) {
checkForDeadProcesses(documents)
}
}, [hasProcessingDocuments, documents, checkForDeadProcesses])
const handleToggleEnabled = (docId: string) => {
const document = documents.find((doc) => doc.id === docId)
@@ -748,6 +677,7 @@ export function KnowledgeBase({
setSelectedDocuments(new Set(documents.map((doc) => doc.id)))
} else {
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
}
}
@@ -793,6 +723,26 @@ export function KnowledgeBase({
* Handles bulk enabling of selected documents
*/
const handleBulkEnable = () => {
if (isSelectAllMode) {
bulkDocumentMutation(
{
knowledgeBaseId: id,
operation: 'enable',
selectAll: true,
enabledFilter,
},
{
onSuccess: (result) => {
logger.info(`Successfully enabled ${result.successCount} documents`)
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
refreshDocuments()
},
}
)
return
}
const documentsToEnable = documents.filter(
(doc) => selectedDocuments.has(doc.id) && !doc.enabled
)
@@ -821,6 +771,26 @@ export function KnowledgeBase({
* Handles bulk disabling of selected documents
*/
const handleBulkDisable = () => {
if (isSelectAllMode) {
bulkDocumentMutation(
{
knowledgeBaseId: id,
operation: 'disable',
selectAll: true,
enabledFilter,
},
{
onSuccess: (result) => {
logger.info(`Successfully disabled ${result.successCount} documents`)
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
refreshDocuments()
},
}
)
return
}
const documentsToDisable = documents.filter(
(doc) => selectedDocuments.has(doc.id) && doc.enabled
)
@@ -845,18 +815,35 @@ export function KnowledgeBase({
)
}
/**
* Opens the bulk delete confirmation modal
*/
const handleBulkDelete = () => {
if (selectedDocuments.size === 0) return
setShowBulkDeleteModal(true)
}
/**
* Confirms and executes the bulk deletion of selected documents
*/
const confirmBulkDelete = () => {
if (isSelectAllMode) {
bulkDocumentMutation(
{
knowledgeBaseId: id,
operation: 'delete',
selectAll: true,
enabledFilter,
},
{
onSuccess: (result) => {
logger.info(`Successfully deleted ${result.successCount} documents`)
refreshDocuments()
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
},
onSettled: () => {
setShowBulkDeleteModal(false)
},
}
)
return
}
const documentsToDelete = documents.filter((doc) => selectedDocuments.has(doc.id))
if (documentsToDelete.length === 0) return
@@ -881,14 +868,17 @@ export function KnowledgeBase({
}
const selectedDocumentsList = documents.filter((doc) => selectedDocuments.has(doc.id))
const enabledCount = selectedDocumentsList.filter((doc) => doc.enabled).length
const disabledCount = selectedDocumentsList.filter((doc) => !doc.enabled).length
const enabledCount = isSelectAllMode
? enabledFilter === 'disabled'
? 0
: pagination.total
: selectedDocumentsList.filter((doc) => doc.enabled).length
const disabledCount = isSelectAllMode
? enabledFilter === 'enabled'
? 0
: pagination.total
: selectedDocumentsList.filter((doc) => !doc.enabled).length
/**
* Handle right-click on a document row
* If right-clicking on an unselected document, select only that document
* If right-clicking on a selected document with multiple selections, keep all selections
*/
const handleDocumentContextMenu = useCallback(
(e: React.MouseEvent, doc: DocumentData) => {
const isCurrentlySelected = selectedDocuments.has(doc.id)
@@ -1005,11 +995,13 @@ export function KnowledgeBase({
</div>
</div>
{knowledgeBase?.description && (
<p className='mt-[4px] line-clamp-2 max-w-[40vw] font-medium text-[14px] text-[var(--text-tertiary)]'>
{knowledgeBase.description}
</p>
)}
<div>
{knowledgeBase?.description && (
<p className='mt-[4px] line-clamp-2 max-w-[40vw] font-medium text-[14px] text-[var(--text-tertiary)]'>
{knowledgeBase.description}
</p>
)}
</div>
<div className='mt-[16px] flex items-center gap-[8px]'>
<span className='text-[14px] text-[var(--text-muted)]'>
@@ -1052,21 +1044,76 @@ export function KnowledgeBase({
))}
</div>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
onClick={handleAddDocuments}
disabled={userPermissions.canEdit !== true}
variant='tertiary'
className='h-[32px] rounded-[6px]'
>
Add Documents
</Button>
</Tooltip.Trigger>
{userPermissions.canEdit !== true && (
<Tooltip.Content>Write permission required to add documents</Tooltip.Content>
)}
</Tooltip.Root>
<div className='flex items-center gap-[8px]'>
<Popover open={isFilterPopoverOpen} onOpenChange={setIsFilterPopoverOpen}>
<PopoverTrigger asChild>
<Button variant='default' className='h-[32px] rounded-[6px]'>
{enabledFilter === 'all'
? 'All'
: enabledFilter === 'enabled'
? 'Enabled'
: 'Disabled'}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</PopoverTrigger>
<PopoverContent align='end' side='bottom' sideOffset={4}>
<div className='flex flex-col gap-[2px]'>
<PopoverItem
active={enabledFilter === 'all'}
onClick={() => {
setEnabledFilter('all')
setIsFilterPopoverOpen(false)
setCurrentPage(1)
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
}}
>
All
</PopoverItem>
<PopoverItem
active={enabledFilter === 'enabled'}
onClick={() => {
setEnabledFilter('enabled')
setIsFilterPopoverOpen(false)
setCurrentPage(1)
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
}}
>
Enabled
</PopoverItem>
<PopoverItem
active={enabledFilter === 'disabled'}
onClick={() => {
setEnabledFilter('disabled')
setIsFilterPopoverOpen(false)
setCurrentPage(1)
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
}}
>
Disabled
</PopoverItem>
</div>
</PopoverContent>
</Popover>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
onClick={handleAddDocuments}
disabled={userPermissions.canEdit !== true}
variant='tertiary'
className='h-[32px] rounded-[6px]'
>
Add Documents
</Button>
</Tooltip.Trigger>
{userPermissions.canEdit !== true && (
<Tooltip.Content>Write permission required to add documents</Tooltip.Content>
)}
</Tooltip.Root>
</div>
</div>
{error && !isLoadingKnowledgeBase && (
@@ -1089,14 +1136,20 @@ export function KnowledgeBase({
<div className='mt-[10px] flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
<div className='text-center'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
{searchQuery ? 'No documents found' : 'No documents yet'}
{searchQuery
? 'No documents found'
: enabledFilter !== 'all'
? 'Nothing matches your filter'
: 'No documents yet'}
</p>
<p className='mt-1 text-[var(--text-muted)] text-xs'>
{searchQuery
? 'Try a different search term'
: userPermissions.canEdit === true
? 'Add documents to get started'
: 'Documents will appear here once added'}
: enabledFilter !== 'all'
? 'Try changing the filter'
: userPermissions.canEdit === true
? 'Add documents to get started'
: 'Documents will appear here once added'}
</p>
</div>
</div>
@@ -1120,7 +1173,7 @@ export function KnowledgeBase({
{renderSortableHeader('tokenCount', 'Tokens', 'hidden w-[8%] lg:table-cell')}
{renderSortableHeader('chunkCount', 'Chunks', 'w-[8%]')}
{renderSortableHeader('uploadedAt', 'Uploaded', 'w-[11%]')}
{renderSortableHeader('processingStatus', 'Status', 'w-[10%]')}
{renderSortableHeader('enabled', 'Status', 'w-[10%]')}
<TableHead className='w-[12%] px-[12px] py-[8px] text-[12px] text-[var(--text-secondary)]'>
Tags
</TableHead>
@@ -1164,7 +1217,10 @@ export function KnowledgeBase({
</TableCell>
<TableCell className='w-[180px] max-w-[180px] px-[12px] py-[8px]'>
<div className='flex min-w-0 items-center gap-[8px]'>
{getFileIcon(doc.mimeType, doc.filename)}
{(() => {
const IconComponent = getDocumentIcon(doc.mimeType, doc.filename)
return <IconComponent className='h-6 w-5 flex-shrink-0' />
})()}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span
@@ -1508,6 +1564,14 @@ export function KnowledgeBase({
enabledCount={enabledCount}
disabledCount={disabledCount}
isLoading={isBulkOperating}
totalCount={pagination.total}
isAllPageSelected={isAllSelected}
isAllSelected={isSelectAllMode}
onSelectAll={() => setIsSelectAllMode(true)}
onClearSelectAll={() => {
setIsSelectAllMode(false)
setSelectedDocuments(new Set())
}}
/>
<DocumentContextMenu

View File

@@ -13,6 +13,11 @@ interface ActionBarProps {
disabledCount?: number
isLoading?: boolean
className?: string
totalCount?: number
isAllPageSelected?: boolean
isAllSelected?: boolean
onSelectAll?: () => void
onClearSelectAll?: () => void
}
export function ActionBar({
@@ -24,14 +29,21 @@ export function ActionBar({
disabledCount = 0,
isLoading = false,
className,
totalCount = 0,
isAllPageSelected = false,
isAllSelected = false,
onSelectAll,
onClearSelectAll,
}: ActionBarProps) {
const userPermissions = useUserPermissionsContext()
if (selectedCount === 0) return null
if (selectedCount === 0 && !isAllSelected) return null
const canEdit = userPermissions.canEdit
const showEnableButton = disabledCount > 0 && onEnable && canEdit
const showDisableButton = enabledCount > 0 && onDisable && canEdit
const showSelectAllOption =
isAllPageSelected && !isAllSelected && totalCount > selectedCount && onSelectAll
return (
<motion.div
@@ -43,7 +55,31 @@ export function ActionBar({
>
<div className='flex items-center gap-[8px] rounded-[10px] border border-[var(--border)] bg-[var(--surface-2)] px-[8px] py-[6px]'>
<span className='px-[4px] text-[13px] text-[var(--text-secondary)]'>
{selectedCount} selected
{isAllSelected ? totalCount : selectedCount} selected
{showSelectAllOption && (
<>
{' · '}
<button
type='button'
onClick={onSelectAll}
className='text-[var(--brand-primary)] hover:underline'
>
Select all
</button>
</>
)}
{isAllSelected && onClearSelectAll && (
<>
{' · '}
<button
type='button'
onClick={onClearSelectAll}
className='text-[var(--brand-primary)] hover:underline'
>
Clear
</button>
</>
)}
</span>
<div className='flex items-center gap-[5px]'>

View File

@@ -123,7 +123,11 @@ export function RenameDocumentModal({
>
Cancel
</Button>
<Button variant='tertiary' type='submit' disabled={isSubmitting || !name?.trim()}>
<Button
variant='tertiary'
type='submit'
disabled={isSubmitting || !name?.trim() || name.trim() === initialName}
>
{isSubmitting ? 'Renaming...' : 'Rename'}
</Button>
</div>

View File

@@ -3,6 +3,7 @@
import { useCallback, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { Badge, DocumentAttachment, Tooltip } from '@/components/emcn'
import { formatAbsoluteDate, formatRelativeTime } from '@/lib/core/utils/formatting'
import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
@@ -21,55 +22,6 @@ interface BaseCardProps {
onDelete?: (id: string) => Promise<void>
}
/**
* Formats a date string to relative time (e.g., "2h ago", "3d ago")
*/
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffInSeconds < 60) {
return 'just now'
}
if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60)
return `${minutes}m ago`
}
if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600)
return `${hours}h ago`
}
if (diffInSeconds < 604800) {
const days = Math.floor(diffInSeconds / 86400)
return `${days}d ago`
}
if (diffInSeconds < 2592000) {
const weeks = Math.floor(diffInSeconds / 604800)
return `${weeks}w ago`
}
if (diffInSeconds < 31536000) {
const months = Math.floor(diffInSeconds / 2592000)
return `${months}mo ago`
}
const years = Math.floor(diffInSeconds / 31536000)
return `${years}y ago`
}
/**
* Formats a date string to absolute format for tooltip display
*/
function formatAbsoluteDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
/**
* Skeleton placeholder for a knowledge base card
*/

View File

@@ -344,53 +344,51 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
<Textarea
id='description'
placeholder='Describe this knowledge base (optional)'
rows={3}
rows={4}
{...register('description')}
className={cn(errors.description && 'border-[var(--text-error)]')}
/>
</div>
<div className='space-y-[12px] rounded-[6px] bg-[var(--surface-5)] px-[12px] py-[14px]'>
<div className='grid grid-cols-2 gap-[12px]'>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='minChunkSize'>Min Chunk Size (characters)</Label>
<Input
id='minChunkSize'
placeholder='100'
{...register('minChunkSize', { valueAsNumber: true })}
className={cn(errors.minChunkSize && 'border-[var(--text-error)]')}
autoComplete='off'
data-form-type='other'
name='min-chunk-size'
/>
</div>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='maxChunkSize'>Max Chunk Size (tokens)</Label>
<Input
id='maxChunkSize'
placeholder='1024'
{...register('maxChunkSize', { valueAsNumber: true })}
className={cn(errors.maxChunkSize && 'border-[var(--text-error)]')}
autoComplete='off'
data-form-type='other'
name='max-chunk-size'
/>
</div>
<div className='grid grid-cols-2 gap-[12px]'>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='minChunkSize'>Min Chunk Size (characters)</Label>
<Input
id='minChunkSize'
placeholder='100'
{...register('minChunkSize', { valueAsNumber: true })}
className={cn(errors.minChunkSize && 'border-[var(--text-error)]')}
autoComplete='off'
data-form-type='other'
name='min-chunk-size'
/>
</div>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='overlapSize'>Overlap (tokens)</Label>
<Label htmlFor='maxChunkSize'>Max Chunk Size (tokens)</Label>
<Input
id='overlapSize'
placeholder='200'
{...register('overlapSize', { valueAsNumber: true })}
className={cn(errors.overlapSize && 'border-[var(--text-error)]')}
id='maxChunkSize'
placeholder='1024'
{...register('maxChunkSize', { valueAsNumber: true })}
className={cn(errors.maxChunkSize && 'border-[var(--text-error)]')}
autoComplete='off'
data-form-type='other'
name='overlap-size'
name='max-chunk-size'
/>
</div>
</div>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='overlapSize'>Overlap (tokens)</Label>
<Input
id='overlapSize'
placeholder='200'
{...register('overlapSize', { valueAsNumber: true })}
className={cn(errors.overlapSize && 'border-[var(--text-error)]')}
autoComplete='off'
data-form-type='other'
name='overlap-size'
/>
<p className='text-[11px] text-[var(--text-muted)]'>
1 token 4 characters. Max chunk size and overlap are in tokens.
</p>

View File

@@ -59,7 +59,7 @@ export function EditKnowledgeBaseModal({
handleSubmit,
reset,
watch,
formState: { errors },
formState: { errors, isDirty },
} = useForm<FormValues>({
resolver: zodResolver(FormSchema),
defaultValues: {
@@ -127,7 +127,7 @@ export function EditKnowledgeBaseModal({
<Textarea
id='description'
placeholder='Describe this knowledge base (optional)'
rows={3}
rows={4}
{...register('description')}
className={cn(errors.description && 'border-[var(--text-error)]')}
/>
@@ -161,7 +161,7 @@ export function EditKnowledgeBaseModal({
<Button
variant='tertiary'
type='submit'
disabled={isSubmitting || !nameValue?.trim()}
disabled={isSubmitting || !nameValue?.trim() || !isDirty}
>
{isSubmitting ? 'Saving...' : 'Save'}
</Button>

View File

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

View File

@@ -2,8 +2,7 @@
import type React from 'react'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import { ArrowDown, ArrowUp, X } from 'lucide-react'
import { ArrowDown, ArrowUp, Check, Clipboard, Search, X } from 'lucide-react'
import { createPortal } from 'react-dom'
import {
Button,
@@ -15,9 +14,11 @@ import {
PopoverContent,
PopoverDivider,
PopoverItem,
Tooltip,
} from '@/components/emcn'
import { WorkflowIcon } from '@/components/icons'
import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
import { getBlock, getBlockByToolName } from '@/blocks'
@@ -26,7 +27,6 @@ import type { TraceSpan } from '@/stores/logs/filters/types'
interface TraceSpansProps {
traceSpans?: TraceSpan[]
totalDuration?: number
}
/**
@@ -100,6 +100,20 @@ function parseTime(value?: string | number | null): number {
return Number.isFinite(ms) ? ms : 0
}
/**
* Checks if a span or any of its descendants has an error
*/
function hasErrorInTree(span: TraceSpan): boolean {
if (span.status === 'error') return true
if (span.children && span.children.length > 0) {
return span.children.some((child) => hasErrorInTree(child))
}
if (span.toolCalls && span.toolCalls.length > 0) {
return span.toolCalls.some((tc) => tc.error)
}
return false
}
/**
* Normalizes and sorts trace spans recursively.
* Merges children from both span.children and span.output.childTraceSpans,
@@ -142,14 +156,6 @@ function normalizeAndSortSpans(spans: TraceSpan[]): TraceSpan[] {
const DEFAULT_BLOCK_COLOR = '#6b7280'
/**
* Formats duration in ms
*/
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(2)}s`
}
/**
* Gets icon and color for a span type using block config
*/
@@ -230,7 +236,7 @@ function ProgressBar({
}, [span, childSpans, workflowStartTime, totalDuration])
return (
<div className='relative mb-[8px] h-[5px] w-full overflow-hidden rounded-[18px] bg-[var(--divider)]'>
<div className='relative h-[5px] w-full overflow-hidden rounded-[18px] bg-[var(--divider)]'>
{segments.map((segment, index) => (
<div
key={index}
@@ -246,143 +252,6 @@ function ProgressBar({
)
}
interface ExpandableRowHeaderProps {
name: string
duration: number
isError: boolean
isExpanded: boolean
hasChildren: boolean
showIcon: boolean
icon: React.ComponentType<{ className?: string }> | null
bgColor: string
onToggle: () => void
}
/**
* Reusable expandable row header with chevron, icon, name, and duration
*/
function ExpandableRowHeader({
name,
duration,
isError,
isExpanded,
hasChildren,
showIcon,
icon: Icon,
bgColor,
onToggle,
}: ExpandableRowHeaderProps) {
return (
<div
className={clsx('group flex items-center justify-between', hasChildren && 'cursor-pointer')}
onClick={hasChildren ? onToggle : undefined}
onKeyDown={
hasChildren
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}
: undefined
}
role={hasChildren ? 'button' : undefined}
tabIndex={hasChildren ? 0 : undefined}
aria-expanded={hasChildren ? isExpanded : undefined}
aria-label={hasChildren ? (isExpanded ? 'Collapse' : 'Expand') : undefined}
>
<div className='flex items-center gap-[8px]'>
{hasChildren && (
<ChevronDown
className='h-[10px] w-[10px] flex-shrink-0 text-[var(--text-tertiary)] transition-transform duration-100 group-hover:text-[var(--text-primary)]'
style={{ transform: isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)' }}
/>
)}
{showIcon && (
<div
className='relative flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: bgColor }}
>
{Icon && <Icon className={clsx('text-white', '!h-[9px] !w-[9px]')} />}
</div>
)}
<span
className='font-medium text-[12px]'
style={{ color: isError ? 'var(--text-error)' : 'var(--text-secondary)' }}
>
{name}
</span>
</div>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
{formatDuration(duration)}
</span>
</div>
)
}
interface SpanContentProps {
span: TraceSpan
spanId: string
isError: boolean
workflowStartTime: number
totalDuration: number
expandedSections: Set<string>
onToggle: (section: string) => void
}
/**
* Reusable component for rendering span content (progress bar + input/output sections)
*/
function SpanContent({
span,
spanId,
isError,
workflowStartTime,
totalDuration,
expandedSections,
onToggle,
}: SpanContentProps) {
const hasInput = Boolean(span.input)
const hasOutput = Boolean(span.output)
return (
<>
<ProgressBar
span={span}
childSpans={span.children}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
/>
{hasInput && (
<InputOutputSection
label='Input'
data={span.input}
isError={false}
spanId={spanId}
sectionType='input'
expandedSections={expandedSections}
onToggle={onToggle}
/>
)}
{hasInput && hasOutput && <div className='border-[var(--border)] border-t border-dashed' />}
{hasOutput && (
<InputOutputSection
label={isError ? 'Error' : 'Output'}
data={span.output}
isError={isError}
spanId={spanId}
sectionType='output'
expandedSections={expandedSections}
onToggle={onToggle}
/>
)}
</>
)
}
/**
* Renders input/output section with collapsible content, context menu, and search
*/
@@ -406,16 +275,14 @@ function InputOutputSection({
const sectionKey = `${spanId}-${sectionType}`
const isExpanded = expandedSections.has(sectionKey)
const contentRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
// Context menu state
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
const [copied, setCopied] = useState(false)
// Code viewer features
const {
wrapText,
toggleWrapText,
isSearchActive,
searchQuery,
setSearchQuery,
@@ -447,6 +314,8 @@ function InputOutputSection({
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(jsonString)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
closeContextMenu()
}, [jsonString, closeContextMenu])
@@ -455,13 +324,8 @@ function InputOutputSection({
closeContextMenu()
}, [activateSearch, closeContextMenu])
const handleToggleWrap = useCallback(() => {
toggleWrapText()
closeContextMenu()
}, [toggleWrapText, closeContextMenu])
return (
<div className='relative flex min-w-0 flex-col gap-[8px] overflow-hidden'>
<div className='relative flex min-w-0 flex-col gap-[6px] overflow-hidden'>
<div
className='group flex cursor-pointer items-center justify-between'
onClick={() => onToggle(sectionKey)}
@@ -477,7 +341,7 @@ function InputOutputSection({
aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${label.toLowerCase()}`}
>
<span
className={clsx(
className={cn(
'font-medium text-[12px] transition-colors',
isError
? 'text-[var(--text-error)]'
@@ -487,9 +351,7 @@ function InputOutputSection({
{label}
</span>
<ChevronDown
className={clsx(
'h-[10px] w-[10px] text-[var(--text-tertiary)] transition-colors transition-transform group-hover:text-[var(--text-primary)]'
)}
className='h-[8px] w-[8px] text-[var(--text-tertiary)] transition-colors transition-transform group-hover:text-[var(--text-primary)]'
style={{
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
}}
@@ -497,16 +359,57 @@ function InputOutputSection({
</div>
{isExpanded && (
<>
<div ref={contentRef} onContextMenu={handleContextMenu}>
<div ref={contentRef} onContextMenu={handleContextMenu} className='relative'>
<Code.Viewer
code={jsonString}
language='json'
className='!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
wrapText={wrapText}
className='!bg-[var(--surface-4)] dark:!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
wrapText
searchQuery={isSearchActive ? searchQuery : undefined}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
/>
{/* Glass action buttons overlay */}
{!isSearchActive && (
<div className='absolute top-[7px] right-[6px] z-10 flex gap-[4px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='default'
onClick={(e) => {
e.stopPropagation()
handleCopy()
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
>
{copied ? (
<Check className='h-[10px] w-[10px] text-[var(--text-success)]' />
) : (
<Clipboard className='h-[10px] w-[10px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{copied ? 'Copied' : 'Copy'}</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='default'
onClick={(e) => {
e.stopPropagation()
activateSearch()
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
>
<Search className='h-[10px] w-[10px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Search</Tooltip.Content>
</Tooltip.Root>
</div>
)}
</div>
{/* Search Overlay */}
@@ -579,13 +482,10 @@ function InputOutputSection({
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
<PopoverContent align='start' side='bottom' sideOffset={4}>
<PopoverItem onClick={handleCopy}>Copy</PopoverItem>
<PopoverDivider />
<PopoverItem onClick={handleSearch}>Search</PopoverItem>
<PopoverItem showCheck={wrapText} onClick={handleToggleWrap}>
Wrap Text
</PopoverItem>
</PopoverContent>
</Popover>,
document.body
@@ -596,355 +496,229 @@ function InputOutputSection({
)
}
interface NestedBlockItemProps {
interface TraceSpanNodeProps {
span: TraceSpan
parentId: string
index: number
workflowStartTime: number
totalDuration: number
depth: number
expandedNodes: Set<string>
expandedSections: Set<string>
onToggle: (section: string) => void
workflowStartTime: number
totalDuration: number
expandedChildren: Set<string>
onToggleChildren: (spanId: string) => void
onToggleNode: (nodeId: string) => void
onToggleSection: (section: string) => void
}
/**
* Recursive component for rendering nested blocks at any depth
* Recursive tree node component for rendering trace spans
*/
function NestedBlockItem({
const TraceSpanNode = memo(function TraceSpanNode({
span,
parentId,
index,
workflowStartTime,
totalDuration,
depth,
expandedNodes,
expandedSections,
onToggle,
workflowStartTime,
totalDuration,
expandedChildren,
onToggleChildren,
}: NestedBlockItemProps): React.ReactNode {
const spanId = span.id || `${parentId}-nested-${index}`
const isError = span.status === 'error'
const { icon: SpanIcon, bgColor } = getBlockIconAndColor(span.type, span.name)
const hasChildren = Boolean(span.children && span.children.length > 0)
const isChildrenExpanded = expandedChildren.has(spanId)
return (
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden'>
<ExpandableRowHeader
name={span.name}
duration={span.duration || 0}
isError={isError}
isExpanded={isChildrenExpanded}
hasChildren={hasChildren}
showIcon={!isIterationType(span.type)}
icon={SpanIcon}
bgColor={bgColor}
onToggle={() => onToggleChildren(spanId)}
/>
<SpanContent
span={span}
spanId={spanId}
isError={isError}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
expandedSections={expandedSections}
onToggle={onToggle}
/>
{/* Nested children */}
{hasChildren && isChildrenExpanded && (
<div className='mt-[2px] flex min-w-0 flex-col gap-[10px] overflow-hidden border-[var(--border)] border-l pl-[10px]'>
{span.children!.map((child, childIndex) => (
<NestedBlockItem
key={child.id || `${spanId}-child-${childIndex}`}
span={child}
parentId={spanId}
index={childIndex}
expandedSections={expandedSections}
onToggle={onToggle}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
expandedChildren={expandedChildren}
onToggleChildren={onToggleChildren}
/>
))}
</div>
)}
</div>
)
}
interface TraceSpanItemProps {
span: TraceSpan
totalDuration: number
workflowStartTime: number
isFirstSpan?: boolean
}
/**
* Individual trace span card component.
* Memoized to prevent re-renders when sibling spans change.
*/
const TraceSpanItem = memo(function TraceSpanItem({
span,
totalDuration,
workflowStartTime,
isFirstSpan = false,
}: TraceSpanItemProps): React.ReactNode {
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set())
const [expandedChildren, setExpandedChildren] = useState<Set<string>>(new Set())
const [isCardExpanded, setIsCardExpanded] = useState(false)
const toggleSet = useSetToggle()
onToggleNode,
onToggleSection,
}: TraceSpanNodeProps): React.ReactNode {
const spanId = span.id || `span-${span.name}-${span.startTime}`
const spanStartTime = new Date(span.startTime).getTime()
const spanEndTime = new Date(span.endTime).getTime()
const duration = span.duration || spanEndTime - spanStartTime
const hasChildren = Boolean(span.children && span.children.length > 0)
const hasToolCalls = Boolean(span.toolCalls && span.toolCalls.length > 0)
const isError = span.status === 'error'
const inlineChildTypes = new Set([
'tool',
'model',
'loop-iteration',
'parallel-iteration',
'workflow',
])
// For workflow-in-workflow blocks, all children should be rendered inline/nested
const isWorkflowBlock = span.type?.toLowerCase().includes('workflow')
const inlineChildren = isWorkflowBlock
? span.children || []
: span.children?.filter((child) => inlineChildTypes.has(child.type?.toLowerCase() || '')) || []
const otherChildren = isWorkflowBlock
? []
: span.children?.filter((child) => !inlineChildTypes.has(child.type?.toLowerCase() || '')) || []
const toolCallSpans = useMemo(() => {
if (!hasToolCalls) return []
return span.toolCalls!.map((toolCall, index) => {
const toolStartTime = toolCall.startTime
? new Date(toolCall.startTime).getTime()
: spanStartTime
const toolEndTime = toolCall.endTime
? new Date(toolCall.endTime).getTime()
: toolStartTime + (toolCall.duration || 0)
return {
id: `${spanId}-tool-${index}`,
name: toolCall.name,
type: 'tool',
duration: toolCall.duration || toolEndTime - toolStartTime,
startTime: new Date(toolStartTime).toISOString(),
endTime: new Date(toolEndTime).toISOString(),
status: toolCall.error ? ('error' as const) : ('success' as const),
input: toolCall.input,
output: toolCall.error
? { error: toolCall.error, ...(toolCall.output || {}) }
: toolCall.output,
} as TraceSpan
})
}, [hasToolCalls, span.toolCalls, spanId, spanStartTime])
const handleSectionToggle = useCallback(
(section: string) => toggleSet(setExpandedSections, section),
[toggleSet]
)
const handleChildrenToggle = useCallback(
(childSpanId: string) => toggleSet(setExpandedChildren, childSpanId),
[toggleSet]
)
const isDirectError = span.status === 'error'
const hasNestedError = hasErrorInTree(span)
const showErrorStyle = isDirectError || hasNestedError
const { icon: BlockIcon, bgColor } = getBlockIconAndColor(span.type, span.name)
// Check if this card has expandable inline content
const hasInlineContent =
(isWorkflowBlock && inlineChildren.length > 0) ||
(!isWorkflowBlock && (toolCallSpans.length > 0 || inlineChildren.length > 0))
// Root workflow execution is always expanded and has no toggle
const isRootWorkflow = depth === 0
const isExpandable = !isFirstSpan && hasInlineContent
// Build all children including tool calls
const allChildren = useMemo(() => {
const children: TraceSpan[] = []
// Add tool calls as child spans
if (span.toolCalls && span.toolCalls.length > 0) {
span.toolCalls.forEach((toolCall, index) => {
const toolStartTime = toolCall.startTime
? new Date(toolCall.startTime).getTime()
: spanStartTime
const toolEndTime = toolCall.endTime
? new Date(toolCall.endTime).getTime()
: toolStartTime + (toolCall.duration || 0)
children.push({
id: `${spanId}-tool-${index}`,
name: toolCall.name,
type: 'tool',
duration: toolCall.duration || toolEndTime - toolStartTime,
startTime: new Date(toolStartTime).toISOString(),
endTime: new Date(toolEndTime).toISOString(),
status: toolCall.error ? ('error' as const) : ('success' as const),
input: toolCall.input,
output: toolCall.error
? { error: toolCall.error, ...(toolCall.output || {}) }
: toolCall.output,
} as TraceSpan)
})
}
// Add regular children
if (span.children && span.children.length > 0) {
children.push(...span.children)
}
// Sort by start time
return children.sort((a, b) => parseTime(a.startTime) - parseTime(b.startTime))
}, [span, spanId, spanStartTime])
const hasChildren = allChildren.length > 0
const isExpanded = isRootWorkflow || expandedNodes.has(spanId)
const isToggleable = !isRootWorkflow
const hasInput = Boolean(span.input)
const hasOutput = Boolean(span.output)
// For progress bar - show child segments for workflow/iteration types
const lowerType = span.type?.toLowerCase() || ''
const showChildrenInProgressBar =
isIterationType(lowerType) || lowerType === 'workflow' || lowerType === 'workflow_input'
return (
<>
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px]'>
<ExpandableRowHeader
name={span.name}
duration={duration}
isError={isError}
isExpanded={isCardExpanded}
hasChildren={isExpandable}
showIcon={!isFirstSpan}
icon={BlockIcon}
bgColor={bgColor}
onToggle={() => setIsCardExpanded((prev) => !prev)}
/>
<SpanContent
span={span}
spanId={spanId}
isError={isError}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
expandedSections={expandedSections}
onToggle={handleSectionToggle}
/>
{/* For workflow blocks, keep children nested within the card (not as separate cards) */}
{!isFirstSpan && isWorkflowBlock && inlineChildren.length > 0 && isCardExpanded && (
<div className='mt-[2px] flex min-w-0 flex-col gap-[10px] overflow-hidden border-[var(--border)] border-l pl-[10px]'>
{inlineChildren.map((childSpan, index) => (
<NestedBlockItem
key={childSpan.id || `${spanId}-nested-${index}`}
span={childSpan}
parentId={spanId}
index={index}
expandedSections={expandedSections}
onToggle={handleSectionToggle}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
expandedChildren={expandedChildren}
onToggleChildren={handleChildrenToggle}
/>
))}
</div>
)}
{/* For non-workflow blocks, render inline children/tool calls */}
{!isFirstSpan && !isWorkflowBlock && isCardExpanded && (
<div className='mt-[2px] flex min-w-0 flex-col gap-[10px] overflow-hidden border-[var(--border)] border-l pl-[10px]'>
{[...toolCallSpans, ...inlineChildren].map((childSpan, index) => {
const childId = childSpan.id || `${spanId}-inline-${index}`
const childIsError = childSpan.status === 'error'
const childLowerType = childSpan.type?.toLowerCase() || ''
const hasNestedChildren = Boolean(childSpan.children && childSpan.children.length > 0)
const isNestedExpanded = expandedChildren.has(childId)
const showChildrenInProgressBar =
isIterationType(childLowerType) || childLowerType === 'workflow'
const { icon: ChildIcon, bgColor: childBgColor } = getBlockIconAndColor(
childSpan.type,
childSpan.name
)
return (
<div
key={`inline-${childId}`}
className='flex min-w-0 flex-col gap-[8px] overflow-hidden'
>
<ExpandableRowHeader
name={childSpan.name}
duration={childSpan.duration || 0}
isError={childIsError}
isExpanded={isNestedExpanded}
hasChildren={hasNestedChildren}
showIcon={!isIterationType(childSpan.type)}
icon={ChildIcon}
bgColor={childBgColor}
onToggle={() => handleChildrenToggle(childId)}
/>
<ProgressBar
span={childSpan}
childSpans={showChildrenInProgressBar ? childSpan.children : undefined}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
/>
{childSpan.input && (
<InputOutputSection
label='Input'
data={childSpan.input}
isError={false}
spanId={childId}
sectionType='input'
expandedSections={expandedSections}
onToggle={handleSectionToggle}
/>
)}
{childSpan.input && childSpan.output && (
<div className='border-[var(--border)] border-t border-dashed' />
)}
{childSpan.output && (
<InputOutputSection
label={childIsError ? 'Error' : 'Output'}
data={childSpan.output}
isError={childIsError}
spanId={childId}
sectionType='output'
expandedSections={expandedSections}
onToggle={handleSectionToggle}
/>
)}
{/* Nested children */}
{showChildrenInProgressBar && hasNestedChildren && isNestedExpanded && (
<div className='mt-[2px] flex min-w-0 flex-col gap-[10px] overflow-hidden border-[var(--border)] border-l pl-[10px]'>
{childSpan.children!.map((nestedChild, nestedIndex) => (
<NestedBlockItem
key={nestedChild.id || `${childId}-nested-${nestedIndex}`}
span={nestedChild}
parentId={childId}
index={nestedIndex}
expandedSections={expandedSections}
onToggle={handleSectionToggle}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
expandedChildren={expandedChildren}
onToggleChildren={handleChildrenToggle}
/>
))}
</div>
)}
</div>
)
})}
</div>
<div className='flex min-w-0 flex-col'>
{/* Node Header Row */}
<div
className={cn(
'group flex items-center justify-between gap-[8px] py-[6px]',
isToggleable && 'cursor-pointer'
)}
onClick={isToggleable ? () => onToggleNode(spanId) : undefined}
onKeyDown={
isToggleable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggleNode(spanId)
}
}
: undefined
}
role={isToggleable ? 'button' : undefined}
tabIndex={isToggleable ? 0 : undefined}
aria-expanded={isToggleable ? isExpanded : undefined}
aria-label={isToggleable ? (isExpanded ? 'Collapse' : 'Expand') : undefined}
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
{!isIterationType(span.type) && (
<div
className='relative flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: bgColor }}
>
{BlockIcon && <BlockIcon className='h-[9px] w-[9px] text-white' />}
</div>
)}
<span
className='min-w-0 max-w-[180px] truncate font-medium text-[12px]'
style={{ color: showErrorStyle ? 'var(--text-error)' : 'var(--text-secondary)' }}
>
{span.name}
</span>
{isToggleable && (
<ChevronDown
className='h-[8px] w-[8px] flex-shrink-0 text-[var(--text-tertiary)] transition-colors transition-transform duration-100 group-hover:text-[var(--text-primary)]'
style={{
transform: `translateY(-0.25px) ${isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)'}`,
}}
/>
)}
</div>
<span className='flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
{formatDuration(duration, { precision: 2 })}
</span>
</div>
{/* For the first span (workflow execution), render all children as separate top-level cards */}
{isFirstSpan &&
hasChildren &&
span.children!.map((childSpan, index) => (
<TraceSpanItem
key={childSpan.id || `${spanId}-child-${index}`}
span={childSpan}
totalDuration={totalDuration}
{/* Expanded Content */}
{isExpanded && (
<div className='flex min-w-0 flex-col gap-[10px]'>
{/* Progress Bar */}
<ProgressBar
span={span}
childSpans={showChildrenInProgressBar ? span.children : undefined}
workflowStartTime={workflowStartTime}
isFirstSpan={false}
totalDuration={totalDuration}
/>
))}
{!isFirstSpan &&
otherChildren.map((childSpan, index) => (
<TraceSpanItem
key={childSpan.id || `${spanId}-other-${index}`}
span={childSpan}
totalDuration={totalDuration}
workflowStartTime={workflowStartTime}
isFirstSpan={false}
/>
))}
</>
{/* Input/Output Sections */}
{(hasInput || hasOutput) && (
<div className='flex min-w-0 flex-col gap-[6px] overflow-hidden py-[2px]'>
{hasInput && (
<InputOutputSection
label='Input'
data={span.input}
isError={false}
spanId={spanId}
sectionType='input'
expandedSections={expandedSections}
onToggle={onToggleSection}
/>
)}
{hasInput && hasOutput && (
<div className='border-[var(--border)] border-t border-dashed' />
)}
{hasOutput && (
<InputOutputSection
label={isDirectError ? 'Error' : 'Output'}
data={span.output}
isError={isDirectError}
spanId={spanId}
sectionType='output'
expandedSections={expandedSections}
onToggle={onToggleSection}
/>
)}
</div>
)}
{/* Nested Children */}
{hasChildren && (
<div className='flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[10px]'>
{allChildren.map((child, index) => (
<div key={child.id || `${spanId}-child-${index}`} className='pl-[6px]'>
<TraceSpanNode
span={child}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
depth={depth + 1}
expandedNodes={expandedNodes}
expandedSections={expandedSections}
onToggleNode={onToggleNode}
onToggleSection={onToggleSection}
/>
</div>
))}
</div>
)}
</div>
)}
</div>
)
})
/**
* Displays workflow execution trace spans with nested structure.
* Displays workflow execution trace spans with nested tree structure.
* Memoized to prevent re-renders when parent LogDetails updates.
*/
export const TraceSpans = memo(function TraceSpans({
traceSpans,
totalDuration = 0,
}: TraceSpansProps) {
export const TraceSpans = memo(function TraceSpans({ traceSpans }: TraceSpansProps) {
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(() => new Set())
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set())
const toggleSet = useSetToggle()
const { workflowStartTime, actualTotalDuration, normalizedSpans } = useMemo(() => {
if (!traceSpans || traceSpans.length === 0) {
return { workflowStartTime: 0, actualTotalDuration: totalDuration, normalizedSpans: [] }
return { workflowStartTime: 0, actualTotalDuration: 0, normalizedSpans: [] }
}
let earliest = Number.POSITIVE_INFINITY
@@ -962,26 +736,37 @@ export const TraceSpans = memo(function TraceSpans({
actualTotalDuration: latest - earliest,
normalizedSpans: normalizeAndSortSpans(traceSpans),
}
}, [traceSpans, totalDuration])
}, [traceSpans])
const handleToggleNode = useCallback(
(nodeId: string) => toggleSet(setExpandedNodes, nodeId),
[toggleSet]
)
const handleToggleSection = useCallback(
(section: string) => toggleSet(setExpandedSections, section),
[toggleSet]
)
if (!traceSpans || traceSpans.length === 0) {
return <div className='text-[12px] text-[var(--text-secondary)]'>No trace data available</div>
}
return (
<div className='flex w-full min-w-0 flex-col gap-[6px] overflow-hidden rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>Trace Span</span>
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden'>
{normalizedSpans.map((span, index) => (
<TraceSpanItem
key={span.id || index}
span={span}
totalDuration={actualTotalDuration}
workflowStartTime={workflowStartTime}
isFirstSpan={index === 0}
/>
))}
</div>
<div className='flex w-full min-w-0 flex-col overflow-hidden'>
{normalizedSpans.map((span, index) => (
<TraceSpanNode
key={span.id || index}
span={span}
workflowStartTime={workflowStartTime}
totalDuration={actualTotalDuration}
depth={0}
expandedNodes={expandedNodes}
expandedSections={expandedSections}
onToggleNode={handleToggleNode}
onToggleSection={handleToggleSection}
/>
))}
</div>
)
})

View File

@@ -1,10 +1,23 @@
'use client'
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronUp, X } from 'lucide-react'
import { Button, Eye } from '@/components/emcn'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ArrowDown, ArrowUp, Check, ChevronUp, Clipboard, Search, X } from 'lucide-react'
import { createPortal } from 'react-dom'
import {
Button,
Code,
Eye,
Input,
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
Tooltip,
} from '@/components/emcn'
import { ScrollArea } from '@/components/ui/scroll-area'
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
import { cn } from '@/lib/core/utils/cn'
import {
ExecutionSnapshot,
FileCards,
@@ -17,11 +30,194 @@ import {
StatusBadge,
TriggerBadge,
} from '@/app/workspace/[workspaceId]/logs/utils'
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { formatCost } from '@/providers/utils'
import type { WorkflowLog } from '@/stores/logs/filters/types'
import { useLogDetailsUIStore } from '@/stores/logs/store'
/**
* Workflow Output section with code viewer, copy, search, and context menu functionality
*/
function WorkflowOutputSection({ output }: { output: Record<string, unknown> }) {
const contentRef = useRef<HTMLDivElement>(null)
const [copied, setCopied] = useState(false)
// Context menu state
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
const {
isSearchActive,
searchQuery,
setSearchQuery,
matchCount,
currentMatchIndex,
activateSearch,
closeSearch,
goToNextMatch,
goToPreviousMatch,
handleMatchCountChange,
searchInputRef,
} = useCodeViewerFeatures({ contentRef })
const jsonString = useMemo(() => JSON.stringify(output, null, 2), [output])
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setContextMenuPosition({ x: e.clientX, y: e.clientY })
setIsContextMenuOpen(true)
}, [])
const closeContextMenu = useCallback(() => {
setIsContextMenuOpen(false)
}, [])
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(jsonString)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
closeContextMenu()
}, [jsonString, closeContextMenu])
const handleSearch = useCallback(() => {
activateSearch()
closeContextMenu()
}, [activateSearch, closeContextMenu])
return (
<div className='relative flex min-w-0 flex-col overflow-hidden'>
<div ref={contentRef} onContextMenu={handleContextMenu} className='relative'>
<Code.Viewer
code={jsonString}
language='json'
className='!bg-[var(--surface-4)] dark:!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
wrapText
searchQuery={isSearchActive ? searchQuery : undefined}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
/>
{/* Glass action buttons overlay */}
{!isSearchActive && (
<div className='absolute top-[7px] right-[6px] z-10 flex gap-[4px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='default'
onClick={(e) => {
e.stopPropagation()
handleCopy()
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
>
{copied ? (
<Check className='h-[10px] w-[10px] text-[var(--text-success)]' />
) : (
<Clipboard className='h-[10px] w-[10px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{copied ? 'Copied' : 'Copy'}</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='default'
onClick={(e) => {
e.stopPropagation()
activateSearch()
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
>
<Search className='h-[10px] w-[10px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Search</Tooltip.Content>
</Tooltip.Root>
</div>
)}
</div>
{/* Search Overlay */}
{isSearchActive && (
<div
className='absolute top-0 right-0 z-30 flex h-[34px] items-center gap-[6px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-1)] px-[6px] shadow-sm'
onClick={(e) => e.stopPropagation()}
>
<Input
ref={searchInputRef}
type='text'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder='Search...'
className='mr-[2px] h-[23px] w-[94px] text-[12px]'
/>
<span
className={cn(
'min-w-[45px] text-center text-[11px]',
matchCount > 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]'
)}
>
{matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'}
</span>
<Button
variant='ghost'
className='!p-1'
onClick={goToPreviousMatch}
disabled={matchCount === 0}
aria-label='Previous match'
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
<Button
variant='ghost'
className='!p-1'
onClick={goToNextMatch}
disabled={matchCount === 0}
aria-label='Next match'
>
<ArrowDown className='h-[12px] w-[12px]' />
</Button>
<Button variant='ghost' className='!p-1' onClick={closeSearch} aria-label='Close search'>
<X className='h-[12px] w-[12px]' />
</Button>
</div>
)}
{/* Context Menu - rendered in portal to avoid transform/overflow clipping */}
{typeof document !== 'undefined' &&
createPortal(
<Popover
open={isContextMenuOpen}
onOpenChange={closeContextMenu}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${contextMenuPosition.x}px`,
top: `${contextMenuPosition.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent align='start' side='bottom' sideOffset={4}>
<PopoverItem onClick={handleCopy}>Copy</PopoverItem>
<PopoverDivider />
<PopoverItem onClick={handleSearch}>Search</PopoverItem>
</PopoverContent>
</Popover>,
document.body
)}
</div>
)
}
interface LogDetailsProps {
/** The log to display details for */
log: WorkflowLog | null
@@ -78,6 +274,18 @@ export const LogDetails = memo(function LogDetails({
return isWorkflowExecutionLog && log?.cost
}, [log, isWorkflowExecutionLog])
// Extract and clean the workflow final output (remove childTraceSpans for cleaner display)
const workflowOutput = useMemo(() => {
const executionData = log?.executionData as
| { finalOutput?: Record<string, unknown> }
| undefined
if (!executionData?.finalOutput) return null
const { childTraceSpans, ...cleanOutput } = executionData.finalOutput as {
childTraceSpans?: unknown
} & Record<string, unknown>
return cleanOutput
}, [log?.executionData])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
@@ -87,12 +295,12 @@ export const LogDetails = memo(function LogDetails({
if (isOpen) {
if (e.key === 'ArrowUp' && hasPrev && onNavigatePrev) {
e.preventDefault()
handleNavigate(onNavigatePrev)
onNavigatePrev()
}
if (e.key === 'ArrowDown' && hasNext && onNavigateNext) {
e.preventDefault()
handleNavigate(onNavigateNext)
onNavigateNext()
}
}
}
@@ -101,10 +309,6 @@ export const LogDetails = memo(function LogDetails({
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isOpen, onClose, hasPrev, hasNext, onNavigatePrev, onNavigateNext])
const handleNavigate = (navigateFunction: () => void) => {
navigateFunction()
}
const formattedTimestamp = useMemo(
() => (log ? formatDate(log.createdAt) : null),
[log?.createdAt]
@@ -142,7 +346,7 @@ export const LogDetails = memo(function LogDetails({
<Button
variant='ghost'
className='!p-[4px]'
onClick={() => hasPrev && handleNavigate(onNavigatePrev!)}
onClick={() => hasPrev && onNavigatePrev?.()}
disabled={!hasPrev}
aria-label='Previous log'
>
@@ -151,7 +355,7 @@ export const LogDetails = memo(function LogDetails({
<Button
variant='ghost'
className='!p-[4px]'
onClick={() => hasNext && handleNavigate(onNavigateNext!)}
onClick={() => hasNext && onNavigateNext?.()}
disabled={!hasNext}
aria-label='Next log'
>
@@ -204,7 +408,7 @@ export const LogDetails = memo(function LogDetails({
{/* Execution ID */}
{log.executionId && (
<div className='flex flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<div className='flex flex-col gap-[6px] rounded-[6px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Execution ID
</span>
@@ -215,7 +419,7 @@ export const LogDetails = memo(function LogDetails({
)}
{/* Details Section */}
<div className='flex min-w-0 flex-col overflow-hidden'>
<div className='-my-[4px] flex min-w-0 flex-col overflow-hidden'>
{/* Level */}
<div className='flex h-[48px] items-center justify-between border-[var(--border)] border-b p-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
@@ -267,19 +471,35 @@ export const LogDetails = memo(function LogDetails({
{/* Workflow State */}
{isWorkflowExecutionLog && log.executionId && !permissionConfig.hideTraceSpans && (
<div className='flex flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<div className='-mt-[8px] flex flex-col gap-[6px] rounded-[6px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Workflow State
</span>
<button
<Button
variant='active'
onClick={() => setIsExecutionSnapshotOpen(true)}
className='flex items-center justify-between rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px] transition-colors hover:bg-[var(--surface-4)]'
className='flex w-full items-center justify-between px-[10px] py-[6px]'
>
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
View Snapshot
</span>
<Eye className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
</button>
<span className='font-medium text-[12px]'>View Snapshot</span>
<Eye className='h-[14px] w-[14px]' />
</Button>
</div>
)}
{/* Workflow Output */}
{isWorkflowExecutionLog && workflowOutput && !permissionConfig.hideTraceSpans && (
<div className='mt-[4px] flex flex-col gap-[6px] rounded-[6px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] py-[8px] dark:bg-transparent'>
<span
className={cn(
'font-medium text-[12px]',
workflowOutput.error
? 'text-[var(--text-error)]'
: 'text-[var(--text-tertiary)]'
)}
>
Workflow Output
</span>
<WorkflowOutputSection output={workflowOutput} />
</div>
)}
@@ -287,10 +507,12 @@ export const LogDetails = memo(function LogDetails({
{isWorkflowExecutionLog &&
log.executionData?.traceSpans &&
!permissionConfig.hideTraceSpans && (
<TraceSpans
traceSpans={log.executionData.traceSpans}
totalDuration={log.executionData.totalDuration}
/>
<div className='mt-[4px] flex flex-col gap-[6px] rounded-[6px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] py-[8px] dark:bg-transparent'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Trace Span
</span>
<TraceSpans traceSpans={log.executionData.traceSpans} />
</div>
)}
{/* Files */}

View File

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

View File

@@ -2,12 +2,12 @@ import { memo, useCallback } from 'react'
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-react'
import { Button, Copy, Tooltip, Trash2 } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { validateTriggerPaste } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useNotificationStore } from '@/stores/notifications'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { getUniqueBlockName, prepareDuplicateBlockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const DEFAULT_DUPLICATE_OFFSET = { x: 50, y: 50 }
@@ -48,29 +48,38 @@ export const ActionBar = memo(
collaborativeBatchToggleBlockEnabled,
collaborativeBatchToggleBlockHandles,
} = useCollaborativeWorkflow()
const { activeWorkflowId, setPendingSelection } = useWorkflowRegistry()
const { setPendingSelection } = useWorkflowRegistry()
const addNotification = useNotificationStore((s) => s.addNotification)
const handleDuplicateBlock = useCallback(() => {
const blocks = useWorkflowStore.getState().blocks
const sourceBlock = blocks[blockId]
if (!sourceBlock) return
const { copyBlocks, preparePasteData, activeWorkflowId } = useWorkflowRegistry.getState()
const existingBlocks = useWorkflowStore.getState().blocks
copyBlocks([blockId])
const newId = crypto.randomUUID()
const newName = getUniqueBlockName(sourceBlock.name, blocks)
const subBlockValues =
useSubBlockStore.getState().workflowValues[activeWorkflowId || '']?.[blockId] || {}
const pasteData = preparePasteData(DEFAULT_DUPLICATE_OFFSET)
if (!pasteData) return
const { block, subBlockValues: filteredValues } = prepareDuplicateBlockState({
sourceBlock,
newId,
newName,
positionOffset: DEFAULT_DUPLICATE_OFFSET,
subBlockValues,
})
const blocks = Object.values(pasteData.blocks)
const validation = validateTriggerPaste(blocks, existingBlocks, 'duplicate')
if (!validation.isValid) {
addNotification({
level: 'error',
message: validation.message!,
workflowId: activeWorkflowId || undefined,
})
return
}
setPendingSelection([newId])
collaborativeBatchAddBlocks([block], [], {}, {}, { [newId]: filteredValues })
}, [blockId, activeWorkflowId, collaborativeBatchAddBlocks, setPendingSelection])
setPendingSelection(blocks.map((b) => b.id))
collaborativeBatchAddBlocks(
blocks,
pasteData.edges,
pasteData.loops,
pasteData.parallels,
pasteData.subBlockValues
)
}, [blockId, addNotification, collaborativeBatchAddBlocks, setPendingSelection])
const { isEnabled, horizontalHandles, parentId, parentType } = useWorkflowStore(
useCallback(
@@ -90,7 +99,7 @@ export const ActionBar = memo(
const userPermissions = useUserPermissionsContext()
const isStartBlock = isValidStartBlockType(blockType)
const isStartBlock = isInputDefinitionTrigger(blockType)
const isResponseBlock = blockType === 'response'
const isNoteBlock = blockType === 'note'
const isSubflowBlock = blockType === 'loop' || blockType === 'parallel'
@@ -119,7 +128,30 @@ export const ActionBar = memo(
'dark:border-transparent dark:bg-[var(--surface-4)]'
)}
>
{!isNoteBlock && !isSubflowBlock && (
{!isNoteBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (!disabled) {
collaborativeBatchToggleBlockEnabled([blockId])
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled}
>
{isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
</Tooltip.Content>
</Tooltip.Root>
)}
{isSubflowBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
@@ -213,29 +245,6 @@ export const ActionBar = memo(
</Tooltip.Root>
)}
{isSubflowBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (!disabled) {
collaborativeBatchToggleBlockEnabled([blockId])
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled}
>
{isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
</Tooltip.Content>
</Tooltip.Root>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button

View File

@@ -8,7 +8,7 @@ import {
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
/**
* Block information for context menu actions
@@ -74,12 +74,16 @@ export function BlockMenu({
const allEnabled = selectedBlocks.every((b) => b.enabled)
const allDisabled = selectedBlocks.every((b) => !b.enabled)
const hasStarterBlock = selectedBlocks.some((b) => isValidStartBlockType(b.type))
const hasSingletonBlock = selectedBlocks.some(
(b) =>
TriggerUtils.requiresSingleInstance(b.type) || TriggerUtils.isSingleInstanceBlockType(b.type)
)
const hasTriggerBlock = selectedBlocks.some((b) => TriggerUtils.isTriggerBlock(b))
const allNoteBlocks = selectedBlocks.every((b) => b.type === 'note')
const isSubflow =
isSingleBlock && (selectedBlocks[0]?.type === 'loop' || selectedBlocks[0]?.type === 'parallel')
const canRemoveFromSubflow = showRemoveFromSubflow && !hasStarterBlock
const canRemoveFromSubflow = showRemoveFromSubflow && !hasTriggerBlock
const getToggleEnabledLabel = () => {
if (allEnabled) return 'Disable'
@@ -127,7 +131,7 @@ export function BlockMenu({
<span>Paste</span>
<span className='ml-auto opacity-70 group-hover:opacity-100'>V</span>
</PopoverItem>
{!hasStarterBlock && (
{!hasSingletonBlock && (
<PopoverItem
disabled={disableEdit}
onClick={() => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ import { Skeleton } from '@/components/ui'
import { isDev } from '@/lib/core/config/feature-flags'
import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl, getEmailDomain } from '@/lib/core/utils/urls'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import {
type FieldConfig,
useCreateForm,
@@ -147,7 +147,7 @@ export function FormDeploy({
useEffect(() => {
const blocks = Object.values(useWorkflowStore.getState().blocks)
const startBlock = blocks.find((b) => isValidStartBlockType(b.type))
const startBlock = blocks.find((b) => isInputDefinitionTrigger(b.type))
if (startBlock) {
const inputFormat = useSubBlockStore.getState().getValue(startBlock.id, 'inputFormat')

View File

@@ -14,7 +14,7 @@ import {
Textarea,
} from '@/components/emcn'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import type { InputFormatField } from '@/lib/workflows/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -52,7 +52,7 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
for (const [blockId, block] of Object.entries(blocks)) {
if (!block || typeof block !== 'object') continue
const blockType = (block as { type?: string }).type
if (blockType && isValidStartBlockType(blockType)) {
if (blockType && isInputDefinitionTrigger(blockType)) {
return blockId
}
}

View File

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

View File

@@ -15,7 +15,7 @@ import {
import { Skeleton } from '@/components/ui'
import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import type { InputFormatField } from '@/lib/workflows/types'
import {
useAddWorkflowMcpTool,
@@ -107,7 +107,7 @@ export function McpDeploy({
for (const [blockId, block] of Object.entries(blocks)) {
if (!block || typeof block !== 'object') continue
const blockType = (block as { type?: string }).type
if (blockType && isValidStartBlockType(blockType)) {
if (blockType && isInputDefinitionTrigger(blockType)) {
return blockId
}
}

View File

@@ -446,7 +446,6 @@ const OGCaptureContainer = forwardRef<HTMLDivElement>((_, ref) => {
isPannable={false}
defaultZoom={0.8}
fitPadding={0.2}
lightweight
/>
</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 { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
import type { GenerationType } from '@/blocks/types'
import { normalizeName } from '@/executor/constants'
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
import { useTagSelection } from '@/hooks/kb/use-tag-selection'
import { normalizeName } from '@/stores/workflows/utils'
const logger = createLogger('Code')

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,12 @@
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import {
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { isEqual } from 'lodash'
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
@@ -382,93 +390,138 @@ export function MessagesInput({
textareaRefs.current[fieldId]?.focus()
}, [])
const autoResizeTextarea = useCallback((fieldId: string) => {
const syncOverlay = useCallback((fieldId: string) => {
const textarea = textareaRefs.current[fieldId]
if (!textarea) return
const overlay = overlayRefs.current[fieldId]
if (!textarea || !overlay) return
// If user has manually resized, respect their chosen height and only sync overlay.
if (userResizedRef.current[fieldId]) {
const currentHeight =
textarea.offsetHeight || Number.parseFloat(textarea.style.height) || MIN_TEXTAREA_HEIGHT_PX
const clampedHeight = Math.max(MIN_TEXTAREA_HEIGHT_PX, currentHeight)
textarea.style.height = `${clampedHeight}px`
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`
}
overlay.style.width = `${textarea.clientWidth}px`
overlay.scrollTop = textarea.scrollTop
overlay.scrollLeft = textarea.scrollLeft
}, [])
const handleResizeStart = useCallback((fieldId: string, e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
const autoResizeTextarea = useCallback(
(fieldId: string) => {
const textarea = textareaRefs.current[fieldId]
const overlay = overlayRefs.current[fieldId]
if (!textarea) return
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]
if (activeTextarea) {
activeTextarea.style.height = `${nextHeight}px`
if (!textarea.value.trim()) {
userResizedRef.current[fieldId] = false
}
const overlay = overlayRefs.current[activeFieldId]
if (userResizedRef.current[fieldId]) {
if (overlay) {
overlay.style.height = `${textarea.offsetHeight}px`
}
syncOverlay(fieldId)
return
}
textarea.style.height = 'auto'
const scrollHeight = textarea.scrollHeight
const height = Math.min(
MAX_TEXTAREA_HEIGHT_PX,
Math.max(MIN_TEXTAREA_HEIGHT_PX, scrollHeight)
)
textarea.style.height = `${height}px`
if (overlay) {
overlay.style.height = `${nextHeight}px`
}
}
const handleMouseUp = () => {
if (resizeStateRef.current) {
const { fieldId: activeFieldId } = resizeStateRef.current
userResizedRef.current[activeFieldId] = true
overlay.style.height = `${height}px`
}
isResizingRef.current = false
resizeStateRef.current = null
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
syncOverlay(fieldId)
},
[syncOverlay]
)
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}, [])
const handleResizeStart = useCallback(
(fieldId: string, e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
useEffect(() => {
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) => {
const fieldId = `message-${index}`
autoResizeTextarea(fieldId)
autoResizeTextarea(`message-${index}`)
})
}, [currentMessages, autoResizeTextarea])
useEffect(() => {
const observers: ResizeObserver[] = []
for (let i = 0; i < currentMessages.length; i++) {
const fieldId = `message-${i}`
const textarea = textareaRefs.current[fieldId]
const overlay = overlayRefs.current[fieldId]
if (textarea && overlay) {
const observer = new ResizeObserver(() => {
overlay.style.width = `${textarea.clientWidth}px`
})
observer.observe(textarea)
observers.push(observer)
}
}
return () => {
observers.forEach((observer) => observer.disconnect())
}
}, [currentMessages.length])
return (
<div className='flex w-full flex-col gap-[10px]'>
{currentMessages.map((message, index) => (
@@ -621,19 +674,15 @@ export function MessagesInput({
</div>
{/* Content Input with overlay for variable highlighting */}
<div className='relative w-full'>
<div className='relative w-full overflow-hidden'>
<textarea
ref={(el) => {
textareaRefs.current[fieldId] = el
}}
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}
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'
placeholder='Enter message content...'
value={message.content}
onChange={(e) => {
fieldHandlers.onChange(e)
autoResizeTextarea(fieldId)
}}
onChange={fieldHandlers.onChange}
onKeyDown={(e) => {
if (e.key === 'Tab' && !isPreview && !disabled) {
e.preventDefault()
@@ -670,12 +719,13 @@ export function MessagesInput({
ref={(el) => {
overlayRefs.current[fieldId] = el
}}
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]'
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'
>
{formatDisplayText(message.content, {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
{message.content.endsWith('\n') && '\u200B'}
</div>
{/* Env var dropdown for this message */}
@@ -705,7 +755,7 @@ export function MessagesInput({
{!isPreview && !disabled && (
<div
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)]'
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)]'
onMouseDown={(e) => handleResizeStart(fieldId, e)}
onDragStart={(e) => {
e.preventDefault()

View File

@@ -35,11 +35,11 @@ import type {
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { getBlock } from '@/blocks'
import type { BlockConfig } from '@/blocks/types'
import { normalizeName } from '@/executor/constants'
import type { Variable } from '@/stores/panel'
import { useVariablesStore } from '@/stores/panel'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { normalizeName } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState } from '@/stores/workflows/workflow/types'
@@ -1312,15 +1312,16 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
if (currentLoop && isLoopBlock) {
containingLoopBlockId = blockId
const loopType = currentLoop.loopType || 'for'
const contextualTags: string[] = ['index']
if (loopType === 'forEach') {
contextualTags.push('currentItem')
contextualTags.push('items')
}
const loopBlock = blocks[blockId]
if (loopBlock) {
const loopBlockName = loopBlock.name || loopBlock.type
const normalizedLoopName = normalizeName(loopBlockName)
const contextualTags: string[] = [`${normalizedLoopName}.index`]
if (loopType === 'forEach') {
contextualTags.push(`${normalizedLoopName}.currentItem`)
contextualTags.push(`${normalizedLoopName}.items`)
}
loopBlockGroup = {
blockName: loopBlockName,
@@ -1328,21 +1329,23 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
blockType: 'loop',
tags: contextualTags,
distance: 0,
isContextual: true,
}
}
} else if (containingLoop) {
const [loopId, loop] = containingLoop
containingLoopBlockId = loopId
const loopType = loop.loopType || 'for'
const contextualTags: string[] = ['index']
if (loopType === 'forEach') {
contextualTags.push('currentItem')
contextualTags.push('items')
}
const containingLoopBlock = blocks[loopId]
if (containingLoopBlock) {
const loopBlockName = containingLoopBlock.name || containingLoopBlock.type
const normalizedLoopName = normalizeName(loopBlockName)
const contextualTags: string[] = [`${normalizedLoopName}.index`]
if (loopType === 'forEach') {
contextualTags.push(`${normalizedLoopName}.currentItem`)
contextualTags.push(`${normalizedLoopName}.items`)
}
loopBlockGroup = {
blockName: loopBlockName,
@@ -1350,6 +1353,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
blockType: 'loop',
tags: contextualTags,
distance: 0,
isContextual: true,
}
}
}
@@ -1363,15 +1367,16 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const [parallelId, parallel] = containingParallel
containingParallelBlockId = parallelId
const parallelType = parallel.parallelType || 'count'
const contextualTags: string[] = ['index']
if (parallelType === 'collection') {
contextualTags.push('currentItem')
contextualTags.push('items')
}
const containingParallelBlock = blocks[parallelId]
if (containingParallelBlock) {
const parallelBlockName = containingParallelBlock.name || containingParallelBlock.type
const normalizedParallelName = normalizeName(parallelBlockName)
const contextualTags: string[] = [`${normalizedParallelName}.index`]
if (parallelType === 'collection') {
contextualTags.push(`${normalizedParallelName}.currentItem`)
contextualTags.push(`${normalizedParallelName}.items`)
}
parallelBlockGroup = {
blockName: parallelBlockName,
@@ -1379,6 +1384,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
blockType: 'parallel',
tags: contextualTags,
distance: 0,
isContextual: true,
}
}
}
@@ -1645,38 +1651,29 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const nestedBlockTagGroups: NestedBlockTagGroup[] = useMemo(() => {
return filteredBlockTagGroups.map((group: BlockTagGroup) => {
const normalizedBlockName = normalizeName(group.blockName)
// Handle loop/parallel contextual tags (index, currentItem, items)
const directTags: NestedTag[] = []
const tagsForTree: string[] = []
group.tags.forEach((tag: string) => {
const tagParts = tag.split('.')
// Loop/parallel contextual tags without block prefix
if (
(group.blockType === 'loop' || group.blockType === 'parallel') &&
tagParts.length === 1
) {
if (tagParts.length === 1) {
directTags.push({
key: tag,
display: tag,
fullTag: tag,
})
} else if (tagParts.length === 2) {
// Direct property like blockname.property
directTags.push({
key: tagParts[1],
display: tagParts[1],
fullTag: tag,
})
} else {
// Nested property - add to tree builder
tagsForTree.push(tag)
}
})
// Build recursive tree from nested tags
const nestedTags = [...directTags, ...buildNestedTagTree(tagsForTree, normalizedBlockName)]
return {
@@ -1800,13 +1797,19 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
processedTag = tag
}
} else if (
blockGroup &&
blockGroup?.isContextual &&
(blockGroup.blockType === 'loop' || blockGroup.blockType === 'parallel')
) {
if (!tag.includes('.') && ['index', 'currentItem', 'items'].includes(tag)) {
processedTag = `${blockGroup.blockType}.${tag}`
const tagParts = tag.split('.')
if (tagParts.length === 1) {
processedTag = blockGroup.blockType
} else {
processedTag = tag
const lastPart = tagParts[tagParts.length - 1]
if (['index', 'currentItem', 'items'].includes(lastPart)) {
processedTag = `${blockGroup.blockType}.${lastPart}`
} else {
processedTag = tag
}
}
}

View File

@@ -7,6 +7,8 @@ export interface BlockTagGroup {
blockType: string
tags: string[]
distance: number
/** True if this is a contextual group (loop/parallel iteration context available inside the subflow) */
isContextual?: boolean
}
/**

View File

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

View File

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

View File

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

View File

@@ -40,6 +40,7 @@ import {
TimeInput,
ToolInput,
VariablesInput,
WorkflowSelectorInput,
} 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 type { SubBlockConfig } from '@/blocks/types'
@@ -90,7 +91,6 @@ const isFieldRequired = (config: SubBlockConfig, subBlockValues?: Record<string,
if (!config.required) return false
if (typeof config.required === 'boolean') return config.required
// Helper function to evaluate a condition
const evalCond = (
cond: {
field: string
@@ -132,7 +132,6 @@ const isFieldRequired = (config: SubBlockConfig, subBlockValues?: Record<string,
return match
}
// If required is a condition object or function, evaluate it
const condition = typeof config.required === 'function' ? config.required() : config.required
return evalCond(condition, subBlockValues || {})
}
@@ -378,7 +377,6 @@ function SubBlockComponent({
setIsValidJson(isValid)
}
// Check if wand is enabled for this sub-block
const isWandEnabled = config.wandConfig?.enabled ?? false
/**
@@ -438,8 +436,6 @@ function SubBlockComponent({
| null
| 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, {
disabled,
isPreview,
@@ -869,6 +865,17 @@ function SubBlockComponent({
/>
)
case 'workflow-selector':
return (
<WorkflowSelectorInput
blockId={blockId}
subBlock={config}
disabled={isDisabled}
isPreview={isPreview}
previewValue={previewValue as string | null}
/>
)
case 'mcp-server-selector':
return (
<McpServerSelector

View File

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

View File

@@ -2,8 +2,18 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { isEqual } from 'lodash'
import { BookOpen, Check, ChevronDown, ChevronUp, Pencil } from 'lucide-react'
import {
BookOpen,
Check,
ChevronDown,
ChevronUp,
ExternalLink,
Loader2,
Pencil,
} from 'lucide-react'
import { useParams } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow'
import { useStoreWithEqualityFn } from 'zustand/traditional'
import { Button, Tooltip } from '@/components/emcn'
import {
buildCanonicalIndex,
@@ -28,8 +38,10 @@ import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/component
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 { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry'
import type { SubBlockType } from '@/blocks/types'
import { useWorkflowState } from '@/hooks/queries/workflows'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePanelEditorStore } from '@/stores/panel'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -84,6 +96,14 @@ export function Editor() {
// Get subflow display properties from configs
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
const subBlocksRef = useRef<HTMLDivElement>(null)
@@ -99,7 +119,8 @@ export function Editor() {
currentWorkflow.isSnapshotView
)
const blockSubBlockValues = useSubBlockStore(
const blockSubBlockValues = useStoreWithEqualityFn(
useSubBlockStore,
useCallback(
(state) => {
if (!activeWorkflowId || !currentBlockId) return EMPTY_SUBBLOCK_VALUES
@@ -252,11 +273,11 @@ export function Editor() {
// Trigger rename mode when signaled from context menu
useEffect(() => {
if (shouldFocusRename && currentBlock && !isSubflow) {
if (shouldFocusRename && currentBlock) {
handleStartRename()
setShouldFocusRename(false)
}
}, [shouldFocusRename, currentBlock, isSubflow, handleStartRename, setShouldFocusRename])
}, [shouldFocusRename, currentBlock, handleStartRename, setShouldFocusRename])
/**
* Handles opening documentation link in a new secure tab.
@@ -268,6 +289,22 @@ 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)
const isConnectionsAtMinHeight = connectionsHeight <= 35
@@ -320,7 +357,7 @@ export function Editor() {
</div>
<div className='flex shrink-0 items-center gap-[8px]'>
{/* Rename button */}
{currentBlock && !isSubflow && (
{currentBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
@@ -406,7 +443,66 @@ export function Editor() {
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]'>
{subBlocks.length === 0 ? (
{/* Workflow Preview - only for workflow blocks with a selected child workflow */}
{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]'>
This block has no subblocks
</div>

View File

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

View File

@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
import { isEqual } from 'lodash'
import { useParams } from 'next/navigation'
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
import { useStoreWithEqualityFn } from 'zustand/traditional'
import { Badge, Tooltip } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -526,7 +527,8 @@ const SubBlockRow = memo(function SubBlockRow({
* 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.
*/
const workflowVariables = useVariablesStore(
const workflowVariables = useStoreWithEqualityFn(
useVariablesStore,
useCallback(
(state) => {
if (!workflowId) return {}
@@ -729,7 +731,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
const isStarterBlock = type === 'starter'
const isWebhookTriggerBlock = type === 'webhook' || type === 'generic_webhook'
const blockSubBlockValues = useSubBlockStore(
const blockSubBlockValues = useStoreWithEqualityFn(
useSubBlockStore,
useCallback(
(state) => {
if (!activeWorkflowId) return EMPTY_SUBBLOCK_VALUES

View File

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

View File

@@ -180,6 +180,21 @@ function mapEdgesByNode(edges: Edge[], nodeIds: Set<string>): Map<string, Edge[]
return result
}
/**
* Syncs the panel editor with the current selection state.
* Shows block details when exactly one block is selected, clears otherwise.
*/
function syncPanelWithSelection(selectedIds: string[]) {
const { currentBlockId, clearCurrentBlock, setCurrentBlockId } = usePanelEditorStore.getState()
if (selectedIds.length === 1 && selectedIds[0] !== currentBlockId) {
setCurrentBlockId(selectedIds[0])
} else if (selectedIds.length === 0 && currentBlockId) {
clearCurrentBlock()
} else if (selectedIds.length > 1 && currentBlockId) {
clearCurrentBlock()
}
}
/** Custom node types for ReactFlow. */
const nodeTypes: NodeTypes = {
workflowBlock: WorkflowBlock,
@@ -2075,7 +2090,10 @@ const WorkflowContent = React.memo(() => {
...node,
selected: pendingSet.has(node.id),
}))
setDisplayNodes(resolveParentChildSelectionConflicts(withSelection, blocks))
const resolved = resolveParentChildSelectionConflicts(withSelection, blocks)
setDisplayNodes(resolved)
const selectedIds = resolved.filter((node) => node.selected).map((node) => node.id)
syncPanelWithSelection(selectedIds)
return
}
@@ -2175,13 +2193,7 @@ const WorkflowContent = React.memo(() => {
})
const selectedIds = selectedIdsRef.current as string[] | null
if (selectedIds !== null) {
const { currentBlockId, clearCurrentBlock, setCurrentBlockId } =
usePanelEditorStore.getState()
if (selectedIds.length === 1 && selectedIds[0] !== currentBlockId) {
setCurrentBlockId(selectedIds[0])
} else if (selectedIds.length === 0 && currentBlockId) {
clearCurrentBlock()
}
syncPanelWithSelection(selectedIds)
}
},
[blocks]

View File

@@ -21,11 +21,9 @@ interface WorkflowPreviewBlockData {
}
/**
* Lightweight block component for workflow previews.
* Renders block header, dummy subblocks skeleton, and handles.
* 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.
* Preview block component for workflow visualization.
* Renders block header, subblocks skeleton, and handles without
* hooks, store subscriptions, or interactive features.
*/
function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>) {
const {

View File

@@ -13,6 +13,7 @@ import {
import { ReactFlowProvider } from 'reactflow'
import { Badge, Button, ChevronDown, Code, Combobox, Input, Label } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
import { extractReferencePrefixes } from '@/lib/workflows/sanitization/references'
import {
buildCanonicalIndex,
@@ -685,7 +686,7 @@ interface WorkflowVariable {
value: unknown
}
interface BlockDetailsSidebarProps {
interface PreviewEditorProps {
block: BlockState
executionData?: ExecutionData
/** All block execution data for resolving variable references */
@@ -704,14 +705,6 @@ interface BlockDetailsSidebarProps {
onClose?: () => void
}
/**
* Format duration for display
*/
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(2)}s`
}
/** Minimum height for the connections section (header only) */
const MIN_CONNECTIONS_HEIGHT = 30
/** Maximum height for the connections section */
@@ -722,7 +715,7 @@ const DEFAULT_CONNECTIONS_HEIGHT = 150
/**
* Readonly sidebar panel showing block configuration using SubBlock components.
*/
function BlockDetailsSidebarContent({
function PreviewEditorContent({
block,
executionData,
allBlockExecutions,
@@ -732,7 +725,7 @@ function BlockDetailsSidebarContent({
parallels,
isExecutionMode = false,
onClose,
}: BlockDetailsSidebarProps) {
}: PreviewEditorProps) {
// Convert Record<string, Variable> to Array<Variable> for iteration
const normalizedWorkflowVariables = useMemo(() => {
if (!workflowVariables) return []
@@ -998,6 +991,22 @@ function BlockDetailsSidebarContent({
})
}, [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)
const isSubflow = block.type === 'loop' || block.type === 'parallel'
const loopConfig = block.type === 'loop' ? loops?.[block.id] : undefined
@@ -1079,21 +1088,6 @@ function BlockDetailsSidebarContent({
)
}
const rawValues = useMemo(() => {
return Object.entries(subBlockValues).reduce<Record<string, unknown>>((acc, [key, entry]) => {
if (entry && typeof entry === 'object' && 'value' in entry) {
acc[key] = (entry as { value: unknown }).value
} else {
acc[key] = entry
}
return acc
}, {})
}, [subBlockValues])
const canonicalIndex = useMemo(
() => buildCanonicalIndex(blockConfig.subBlocks),
[blockConfig.subBlocks]
)
const canonicalModeOverrides = block.data?.canonicalModes
const effectiveAdvanced =
(block.advancedMode ?? false) ||
@@ -1179,7 +1173,7 @@ function BlockDetailsSidebarContent({
)}
{executionData.durationMs !== undefined && (
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
{formatDuration(executionData.durationMs)}
{formatDuration(executionData.durationMs, { precision: 2 })}
</span>
)}
</div>
@@ -1371,12 +1365,12 @@ function BlockDetailsSidebarContent({
}
/**
* Block details sidebar wrapped in ReactFlowProvider for hook compatibility.
* Preview editor wrapped in ReactFlowProvider for hook compatibility.
*/
export function BlockDetailsSidebar(props: BlockDetailsSidebarProps) {
export function PreviewEditor(props: PreviewEditorProps) {
return (
<ReactFlowProvider>
<BlockDetailsSidebarContent {...props} />
<PreviewEditorContent {...props} />
</ReactFlowProvider>
)
}

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,7 @@ import {
} from '@/components/emcn'
import { Input, Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { formatDate } from '@/lib/core/utils/formatting'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
type ApiKey,
@@ -133,13 +134,9 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
}
}, [shouldScrollToBottom])
const formatDate = (dateString?: string) => {
const formatLastUsed = (dateString?: string) => {
if (!dateString) return 'Never'
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
return formatDate(new Date(dateString))
}
return (
@@ -216,7 +213,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
{key.name}
</span>
<span className='text-[13px] text-[var(--text-secondary)]'>
(last used: {formatDate(key.lastUsed).toLowerCase()})
(last used: {formatLastUsed(key.lastUsed).toLowerCase()})
</span>
</div>
<p className='truncate text-[13px] text-[var(--text-muted)]'>
@@ -251,7 +248,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
{key.name}
</span>
<span className='text-[13px] text-[var(--text-secondary)]'>
(last used: {formatDate(key.lastUsed).toLowerCase()})
(last used: {formatLastUsed(key.lastUsed).toLowerCase()})
</span>
</div>
<p className='truncate text-[13px] text-[var(--text-muted)]'>
@@ -291,7 +288,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
{key.name}
</span>
<span className='text-[13px] text-[var(--text-secondary)]'>
(last used: {formatDate(key.lastUsed).toLowerCase()})
(last used: {formatLastUsed(key.lastUsed).toLowerCase()})
</span>
</div>
<p className='truncate text-[13px] text-[var(--text-muted)]'>

View File

@@ -13,6 +13,7 @@ import {
ModalHeader,
} from '@/components/emcn'
import { Input, Skeleton } from '@/components/ui'
import { formatDate } from '@/lib/core/utils/formatting'
import {
type CopilotKey,
useCopilotKeys,
@@ -115,13 +116,9 @@ export function Copilot() {
}
}
const formatDate = (dateString?: string | null) => {
const formatLastUsed = (dateString?: string | null) => {
if (!dateString) return 'Never'
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
return formatDate(new Date(dateString))
}
const hasKeys = keys.length > 0
@@ -180,7 +177,7 @@ export function Copilot() {
{key.name || 'Unnamed Key'}
</span>
<span className='text-[13px] text-[var(--text-secondary)]'>
(last used: {formatDate(key.lastUsed).toLowerCase()})
(last used: {formatLastUsed(key.lastUsed).toLowerCase()})
</span>
</div>
<p className='truncate text-[13px] text-[var(--text-muted)]'>

View File

@@ -11,7 +11,7 @@ import { useSidebarStore } from '@/stores/sidebar/store'
* Avatar display configuration for responsive layout.
*/
const AVATAR_CONFIG = {
MIN_COUNT: 3,
MIN_COUNT: 4,
MAX_COUNT: 12,
WIDTH_PER_AVATAR: 20,
} as const
@@ -106,7 +106,9 @@ export function Avatars({ workflowId }: AvatarsProps) {
}, [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(() => {
if (workflowUsers.length === 0) {
@@ -116,7 +118,8 @@ export function Avatars({ workflowId }: AvatarsProps) {
const visible = workflowUsers.slice(0, maxVisible)
const overflow = Math.max(0, workflowUsers.length - maxVisible)
return { visibleUsers: visible, overflowCount: overflow }
// Reverse so rightmost avatars stay stable as new ones are revealed on the left
return { visibleUsers: [...visible].reverse(), overflowCount: overflow }
}, [workflowUsers, maxVisible])
if (visibleUsers.length === 0) {
@@ -139,9 +142,8 @@ export function Avatars({ workflowId }: AvatarsProps) {
</Tooltip.Content>
</Tooltip.Root>
)}
{visibleUsers.map((user, index) => (
<UserAvatar key={user.socketId} user={user} index={overflowCount > 0 ? index + 1 : index} />
<UserAvatar key={user.socketId} user={user} index={index} />
))}
</div>
)

View File

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

View File

@@ -242,15 +242,9 @@ Return ONLY the email body - no explanations, no extra text.`,
id: 'messageId',
title: 'Message ID',
type: 'short-input',
placeholder: 'Enter message ID to read (optional)',
condition: {
field: 'operation',
value: 'read_gmail',
and: {
field: 'folder',
value: '',
},
},
placeholder: 'Read specific email by ID (overrides label/folder)',
condition: { field: 'operation', value: 'read_gmail' },
mode: 'advanced',
},
// Search Fields
{

View File

@@ -129,12 +129,9 @@ ROUTING RULES:
3. If the context is even partially related to a route's description, select that route
4. ONLY output NO_MATCH if the context is completely unrelated to ALL route descriptions
OUTPUT FORMAT:
- Output EXACTLY one route ID (copied exactly as shown above) OR "NO_MATCH"
- No explanation, no punctuation, no additional text
- Just the route ID or NO_MATCH
Your response:`
Respond with a JSON object containing:
- route: EXACTLY one route ID (copied exactly as shown above) OR "NO_MATCH"
- reasoning: A brief explanation (1-2 sentences) of why you chose this route`
}
/**
@@ -272,6 +269,7 @@ interface RouterV2Response extends ToolResponse {
total: number
}
selectedRoute: string
reasoning: string
selectedPath: {
blockId: string
blockType: string
@@ -355,6 +353,7 @@ export const RouterV2Block: BlockConfig<RouterV2Response> = {
tokens: { type: 'json', description: 'Token usage' },
cost: { type: 'json', description: 'Cost information' },
selectedRoute: { type: 'string', description: 'Selected route ID' },
reasoning: { type: 'string', description: 'Explanation of why this route was chosen' },
selectedPath: { type: 'json', description: 'Selected routing path' },
},
}

View File

@@ -1,30 +1,5 @@
import { createLogger } from '@sim/logger'
import { WorkflowIcon } from '@/components/icons'
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 = {
type: 'workflow',
@@ -38,8 +13,7 @@ export const WorkflowBlock: BlockConfig = {
{
id: 'workflowId',
title: 'Select Workflow',
type: 'combobox',
options: getAvailableWorkflows,
type: 'workflow-selector',
placeholder: 'Search workflows...',
required: true,
},

View File

@@ -1,18 +1,5 @@
import { WorkflowIcon } from '@/components/icons'
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 = {
type: 'workflow_input',
@@ -25,21 +12,19 @@ export const WorkflowInputBlock: BlockConfig = {
`,
category: 'blocks',
docsLink: 'https://docs.sim.ai/blocks/workflow',
bgColor: '#6366F1', // Indigo - modern and professional
bgColor: '#6366F1',
icon: WorkflowIcon,
subBlocks: [
{
id: 'workflowId',
title: 'Select Workflow',
type: 'combobox',
options: getAvailableWorkflows,
type: 'workflow-selector',
placeholder: 'Search workflows...',
required: true,
},
// Renders dynamic mapping UI based on selected child workflow's Start trigger inputFormat
{
id: 'inputMapping',
title: 'Input Mapping',
title: 'Inputs',
type: 'input-mapping',
description:
"Map fields defined in the child workflow's Start block to variables/values in this workflow.",

View File

@@ -23,7 +23,13 @@ import { cn } from '@/lib/core/utils/cn'
* ```
*/
const checkboxVariants = cva(
'peer shrink-0 rounded-sm border border-[var(--border-1)] bg-[var(--surface-4)] ring-offset-background transition-colors hover:border-[var(--border-muted)] hover:bg-[var(--surface-7)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 data-[state=checked]:border-[var(--text-muted)] data-[state=checked]:bg-[var(--text-muted)] data-[state=checked]:text-white dark:bg-[var(--surface-5)] dark:data-[state=checked]:border-[var(--surface-7)] dark:data-[state=checked]:bg-[var(--surface-7)] dark:data-[state=checked]:text-[var(--text-primary)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]',
[
'peer shrink-0 cursor-pointer rounded-[4px] border transition-colors',
'border-[var(--border-1)] bg-transparent',
'focus-visible:outline-none',
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
'data-[state=checked]:border-[var(--text-primary)] data-[state=checked]:bg-[var(--text-primary)]',
].join(' '),
{
variants: {
size: {
@@ -83,7 +89,7 @@ const Checkbox = React.forwardRef<React.ElementRef<typeof CheckboxPrimitive.Root
className={cn(checkboxVariants({ size }), className)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
<CheckboxPrimitive.Indicator className='flex items-center justify-center text-[var(--white)]'>
<Check className={cn(checkboxIconVariants({ size }))} />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>

View File

@@ -0,0 +1 @@
export { DiffControlsDemo } from './components/diff-controls-demo'

View File

@@ -0,0 +1,111 @@
'use client'
import { useState } from 'react'
export function DiffControlsDemo() {
const [rejectHover, setRejectHover] = useState(false)
const [acceptHover, setAcceptHover] = useState(false)
return (
<div style={{ display: 'flex', justifyContent: 'center', margin: '24px 0' }}>
<div
style={{
position: 'relative',
display: 'flex',
height: '30px',
overflow: 'hidden',
borderRadius: '4px',
isolation: 'isolate',
}}
>
{/* Reject button */}
<button
onClick={() => {}}
onMouseEnter={() => setRejectHover(true)}
onMouseLeave={() => setRejectHover(false)}
title='Reject changes'
style={{
position: 'relative',
display: 'flex',
height: '100%',
alignItems: 'center',
border: '1px solid #e0e0e0',
backgroundColor: rejectHover ? '#f0f0f0' : '#f5f5f5',
paddingRight: '20px',
paddingLeft: '12px',
fontWeight: 500,
fontSize: '13px',
color: rejectHover ? '#2d2d2d' : '#404040',
clipPath: 'polygon(0 0, calc(100% + 10px) 0, 100% 100%, 0 100%)',
borderRadius: '4px 0 0 4px',
cursor: 'default',
transition: 'color 150ms, background-color 150ms, border-color 150ms',
}}
>
Reject
</button>
{/* Slanted divider - split gray/green */}
<div
style={{
pointerEvents: 'none',
position: 'absolute',
top: 0,
bottom: 0,
left: '66px',
width: '2px',
transform: 'skewX(-18.4deg)',
background: 'linear-gradient(to right, #e0e0e0 50%, #238458 50%)',
zIndex: 10,
}}
/>
{/* Accept button */}
<button
onClick={() => {}}
onMouseEnter={() => setAcceptHover(true)}
onMouseLeave={() => setAcceptHover(false)}
title='Accept changes (⇧⌘⏎)'
style={{
position: 'relative',
display: 'flex',
height: '100%',
alignItems: 'center',
border: '1px solid rgba(0, 0, 0, 0.15)',
backgroundColor: '#32bd7e',
paddingRight: '12px',
paddingLeft: '20px',
fontWeight: 500,
fontSize: '13px',
color: '#ffffff',
clipPath: 'polygon(10px 0, 100% 0, 100% 100%, 0 100%)',
borderRadius: '0 4px 4px 0',
marginLeft: '-10px',
cursor: 'default',
filter: acceptHover ? 'brightness(1.1)' : undefined,
transition: 'background-color 150ms, border-color 150ms',
}}
>
Accept
<kbd
style={{
marginLeft: '8px',
borderRadius: '4px',
border: '1px solid rgba(255, 255, 255, 0.2)',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
paddingLeft: '6px',
paddingRight: '6px',
paddingTop: '2px',
paddingBottom: '2px',
fontWeight: 500,
fontFamily:
'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"',
fontSize: '10px',
color: '#ffffff',
}}
>
<span style={{ display: 'inline-block', transform: 'translateY(-1px)' }}></span>
</kbd>
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,201 @@
---
slug: v0-5
title: 'Introducing Sim v0.5'
description: 'This new release brings a state of the art Copilot, seamless MCP server and tool deployment, 100+ integrations with 300+ tools, comprehensive execution logs, and realtime collaboration—built for teams shipping AI agents in production.'
date: 2026-01-22
updated: 2026-01-22
authors:
- waleed
readingTime: 8
tags: [Release, Copilot, MCP, Observability, Collaboration, Integrations, Sim]
ogImage: /studio/v0-5/cover.png
ogAlt: 'Sim v0.5 release announcement'
about: ['AI Agents', 'Workflow Automation', 'Developer Tools']
timeRequired: PT8M
canonical: https://sim.ai/studio/v0-5
featured: true
draft: false
---
**Sim v0.5** is the next evolution of our agent workflow platform—built for teams shipping AI agents to production.
## Copilot
![Sim Copilot](/studio/v0-5/copilot.jpg)
Copilot is a context-aware assistant embedded in the Sim editor. Unlike general-purpose AI assistants, Copilot has direct access to your workspace: workflows, block configurations, execution logs, connected credentials, and documentation. It can also search the web to pull in external context when needed.
Your workspace is indexed for hybrid retrieval. When you ask a question, Copilot queries this index to ground its responses in your actual workflow state. Ask "why did my workflow fail at 3am?" and it retrieves the relevant execution trace, identifies the error, and explains what happened.
Copilot supports slash commands that trigger specialized capabilities:
- `/deep-research` — performs multi-step web research on a topic, synthesizing results from multiple sources
- `/api-docs` — fetches and parses API documentation from a URL, extracting endpoints, parameters, and authentication requirements
- `/test` — runs your current workflow with sample inputs and reports results inline
- `/build` — generates a complete workflow from a natural language description, wiring up blocks and configuring integrations
Use `@` commands to pull specific context into your conversation. `@block` references a specific block's configuration and recent outputs. `@workflow` includes the full workflow structure. `@logs` pulls in recent execution traces. This lets you ask targeted questions like "why is `@Slack1` returning an error?" and Copilot has the exact context it needs to diagnose the issue.
For complex tasks, Copilot uses subagents—breaking requests into discrete operations and executing them sequentially. Ask it to "add error handling to this workflow" and it will analyze your blocks, determine where failures could occur, add appropriate condition blocks, and wire up notification paths. Each change surfaces as a diff for your review before applying.
<DiffControlsDemo />
## MCP Deployment
![MCP Deployment](/studio/v0-5/mcp.png)
Deploy any workflow as an [MCP](https://modelcontextprotocol.io) server. Once deployed, the workflow becomes a callable tool for any MCP-compatible agent—[Claude Desktop](https://claude.ai/download), [Cursor](https://cursor.com), or your own applications.
Sim generates a tool definition from your workflow: the name and description you specify, plus a JSON schema derived from your Start block's input format. The MCP server uses Streamable HTTP transport, so agents connect via a single URL. Authentication is handled via API key headers or public access, depending on your configuration.
Consider a lead enrichment workflow: it queries Apollo for contact data, checks Salesforce for existing records, formats the output, and posts a summary to Slack. That's 8 blocks in Sim. Deploy it as MCP, and any agent can call `enrich_lead("jane@acme.com")` and receive structured data back. The agent treats it as a single tool call—it doesn't need to know about Apollo, Salesforce, or Slack.
This pattern scales to research pipelines, data processing workflows, approval chains, and internal tooling. Anything you build in Sim becomes a tool any agent can invoke.
## Logs & Dashboard
![Logs & Dashboard](/studio/v0-5/dashboard.jpg)
Every workflow execution generates a full trace. Each block records its start time, end time, inputs, outputs, and any errors. For LLM blocks, we capture prompt tokens, completion tokens, and cost by model.
The dashboard aggregates this data into queryable views:
- **Trace spans**: Hierarchical view of block executions with timing waterfall
- **Cost attribution**: Token usage and spend broken down by model per execution
- **Error context**: Full stack traces with the block, input values, and failure reason
- **Filtering**: Query by time range, trigger type, workflow, or status
- **Execution snapshots**: Each run captures the workflow state at execution time—restore to see exactly what was running
This level of observability is necessary when workflows handle production traffic—sending customer emails, processing payments, or making API calls on behalf of users.
## Realtime Collaboration
![Realtime Collaboration](/studio/v0-5/collaboration.png)
Multiple users can edit the same workflow simultaneously. Changes propagate in real time—you see teammates' cursors, block additions, and configuration updates as they happen.
The editor now supports full undo/redo history (Cmd+Z / Cmd+Shift+Z), so you can step back through changes without losing work. Copy and paste works for individual blocks, groups of blocks, or entire subflows—select what you need, Cmd+C, and paste into the same workflow or a different one. This makes it easy to duplicate patterns, share components across workflows, or quickly prototype variations.
This is particularly useful during development sessions where engineers, product managers, and domain experts need to iterate together. Everyone works on the same workflow state, and changes sync immediately across all connected clients.
## Versioning
![Versioning](/studio/v0-5/versioning.png)
Every deployment creates a new version. The version history shows who deployed what and when, with a preview of the workflow state at that point in time. Roll back to any previous version with one click—the live deployment updates immediately.
This matters when something breaks in production. You can instantly revert to the last known good version while you debug, rather than scrambling to fix forward. It also provides a clear audit trail: you can see exactly what changed between versions and who made the change.
---
## 100+ Integrations
![Integrations](/studio/v0-5/integrations.png)
v0.5 adds **100+ integrations** with **300+ actions**. These cover the specific operations you need—not just generic CRUD, but actions like "send Slack message to channel," "create Jira ticket with custom fields," "query Postgres with parameterized SQL," or "enrich contact via Apollo."
- **CRMs & Sales**: Salesforce, HubSpot, Pipedrive, Apollo, Wealthbox
- **Communication**: Slack, Discord, Microsoft Teams, Telegram, WhatsApp, Twilio
- **Productivity**: Notion, Confluence, Google Workspace, Microsoft 365, Airtable, Asana, Trello
- **Developer Tools**: GitHub, GitLab, Jira, Linear, Sentry, Datadog, Grafana
- **Databases**: PostgreSQL, MySQL, MongoDB, [Supabase](https://supabase.com), DynamoDB, Elasticsearch, [Pinecone](https://pinecone.io), [Qdrant](https://qdrant.tech), Neo4j
- **Finance**: Stripe, Kalshi, Polymarket
- **Web & Search**: [Firecrawl](https://firecrawl.dev), [Exa](https://exa.ai), [Tavily](https://tavily.com), [Jina](https://jina.ai), [Serper](https://serper.dev)
- **Cloud**: AWS (S3, RDS, SQS, Textract, Bedrock), [Browser Use](https://browser-use.com), [Stagehand](https://github.com/browserbase/stagehand)
Each integration handles OAuth or API key authentication. Connect once, and the credentials are available across all workflows in your workspace.
---
## Triggers
Workflows can be triggered through multiple mechanisms:
**Webhooks**: Sim provisions a unique HTTPS endpoint for each workflow. Incoming POST requests are parsed and passed to the first block as input. Supports standard webhook patterns including signature verification for services that provide it.
**Schedules**: Cron-based scheduling with timezone support. Use the visual scheduler or write expressions directly. Execution locks prevent overlapping runs.
**Chat**: Deploy workflows as conversational interfaces. Messages stream to your workflow, responses stream back to the user. Supports multi-turn context.
**API**: REST endpoint with your workflow's input schema. Call it from any system that can make HTTP requests.
**Integration triggers**: Event-driven triggers for specific services—GitHub (PR opened, issue created, push), Stripe (payment succeeded, subscription updated), TypeForm (form submitted), RSS (new item), and more.
**Forms**: Coming soon—build custom input forms that trigger workflows directly.
---
## Knowledge Base
![Knowledge Base](/studio/v0-5/kb.png)
Upload documents—PDFs, text files, markdown, HTML—and make them queryable by your agents. This is [RAG](https://en.wikipedia.org/wiki/Retrieval-augmented_generation) (Retrieval Augmented Generation) built directly into Sim.
Documents are chunked, embedded, and indexed using hybrid search ([BM25](https://en.wikipedia.org/wiki/Okapi_BM25) + vector embeddings). Agent blocks can query the knowledge base as a tool, retrieving relevant passages based on semantic similarity and keyword matching. When documents are updated, they re-index automatically.
Use cases:
- **Customer support agents** that reference your help docs and troubleshooting guides to resolve tickets
- **Sales assistants** that pull from product specs, pricing sheets, and competitive intel
- **Internal Q&A bots** that answer questions about company policies, HR docs, or engineering runbooks
- **Research workflows** that synthesize information from uploaded papers, reports, or data exports
---
## New Blocks
### Human in the Loop
Pause workflow execution pending human approval. The block sends a notification (email, Slack, or webhook) with approve/reject actions. Execution resumes only on approval—useful for high-stakes operations like customer-facing emails, financial transactions, or content publishing.
### Agent Block
The Agent block now supports three additional tool types:
- **Workflows as tools**: Agents can invoke other Sim workflows, enabling hierarchical architectures where a coordinator agent delegates to specialized sub-workflows
- **Knowledge base queries**: Agents search your indexed documents directly, retrieving relevant context for their responses
- **Custom functions**: Execute JavaScript or Python code in isolated sandboxes with configurable timeout and memory limits
### Subflows
Group blocks into collapsible subflows. Use them for loops (iterate over arrays), parallel execution (run branches concurrently), or logical organization. Subflows can be nested and keep complex workflows manageable.
### Router
Conditional branching based on data or LLM classification. Define rules or let the router use an LLM to determine intent and select the appropriate path.
The router now exposes its reasoning in execution logs—when debugging unexpected routing, you can see exactly why a particular branch was selected.
---
## Model Providers
Sim supports 14 providers: [OpenAI](https://openai.com), [Anthropic](https://anthropic.com), [Google](https://ai.google.dev), [Azure OpenAI](https://azure.microsoft.com/en-us/products/ai-services/openai-service), [xAI](https://x.ai), [Mistral](https://mistral.ai), [Deepseek](https://deepseek.com), [Groq](https://groq.com), [Cerebras](https://cerebras.ai), [Ollama](https://ollama.com), and [OpenRouter](https://openrouter.ai).
New in v0.5:
- **[AWS Bedrock](https://aws.amazon.com/bedrock)**: Claude, Nova, Llama, Mistral, and Cohere models via your AWS account
- **[Google Vertex AI](https://cloud.google.com/vertex-ai)**: Gemini models through Google Cloud
- **[vLLM](https://github.com/vllm-project/vllm)**: Self-hosted models on your own infrastructure
Model selection is per-block, so you can use faster/cheaper models for simple tasks and more capable models where needed.
---
## Developer Experience
**Custom Tools**: Define your own integrations with custom HTTP endpoints, authentication (API key, OAuth, Bearer token), and request/response schemas. Custom tools appear in the block palette alongside built-in integrations.
**Environment Variables**: Encrypted key-value storage for secrets and configuration. Variables are decrypted at runtime and can be referenced in any block configuration.
**Import/Export**: Export workflows or entire workspaces as JSON. Imports preserve all blocks, connections, configurations, and variable references.
**File Manager**: Upload files to your workspace for use in workflows—templates, seed data, static assets. Files are accessible via internal references or presigned URLs.
---
## Get Started
Available now at [sim.ai](https://sim.ai). Check out the [docs](https://docs.sim.ai) to dive deeper.
*Questions? [help@sim.ai](mailto:help@sim.ai) · [Discord](https://sim.ai/discord)*

View File

@@ -120,6 +120,12 @@ export const SPECIAL_REFERENCE_PREFIXES = [
REFERENCE.PREFIX.VARIABLE,
] as const
export const RESERVED_BLOCK_NAMES = [
REFERENCE.PREFIX.LOOP,
REFERENCE.PREFIX.PARALLEL,
REFERENCE.PREFIX.VARIABLE,
] as const
export const LOOP_REFERENCE = {
ITERATION: 'iteration',
INDEX: 'index',

View File

@@ -24,6 +24,71 @@ function createBlock(id: string, metadataId: string): SerializedBlock {
}
}
describe('DAGBuilder disabled subflow validation', () => {
it('skips validation for disabled loops with no blocks inside', () => {
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
{ ...createBlock('loop-block', BlockType.FUNCTION), enabled: false },
],
connections: [],
loops: {
'loop-1': {
id: 'loop-1',
nodes: [], // Empty loop - would normally throw
iterations: 3,
},
},
}
const builder = new DAGBuilder()
// Should not throw even though loop has no blocks inside
expect(() => builder.build(workflow)).not.toThrow()
})
it('skips validation for disabled parallels with no blocks inside', () => {
const workflow: SerializedWorkflow = {
version: '1',
blocks: [createBlock('start', BlockType.STARTER)],
connections: [],
loops: {},
parallels: {
'parallel-1': {
id: 'parallel-1',
nodes: [], // Empty parallel - would normally throw
},
},
}
const builder = new DAGBuilder()
// Should not throw even though parallel has no blocks inside
expect(() => builder.build(workflow)).not.toThrow()
})
it('skips validation for loops where all inner blocks are disabled', () => {
const workflow: SerializedWorkflow = {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
{ ...createBlock('inner-block', BlockType.FUNCTION), enabled: false },
],
connections: [],
loops: {
'loop-1': {
id: 'loop-1',
nodes: ['inner-block'], // Has node but it's disabled
iterations: 3,
},
},
}
const builder = new DAGBuilder()
// Should not throw - loop is effectively disabled since all inner blocks are disabled
expect(() => builder.build(workflow)).not.toThrow()
})
})
describe('DAGBuilder human-in-the-loop transformation', () => {
it('creates trigger nodes and rewires edges for pause blocks', () => {
const workflow: SerializedWorkflow = {

View File

@@ -136,17 +136,18 @@ export class DAGBuilder {
nodes: string[] | undefined,
type: 'Loop' | 'Parallel'
): void {
const sentinelStartId =
type === 'Loop' ? buildSentinelStartId(id) : buildParallelSentinelStartId(id)
const sentinelStartNode = dag.nodes.get(sentinelStartId)
if (!sentinelStartNode) return
if (!nodes || nodes.length === 0) {
throw new Error(
`${type} has no blocks inside. Add at least one block to the ${type.toLowerCase()}.`
)
}
const sentinelStartId =
type === 'Loop' ? buildSentinelStartId(id) : buildParallelSentinelStartId(id)
const sentinelStartNode = dag.nodes.get(sentinelStartId)
if (!sentinelStartNode) return
const hasConnections = Array.from(sentinelStartNode.outgoingEdges.values()).some((edge) =>
nodes.includes(extractBaseBlockId(edge.target))
)

File diff suppressed because it is too large Load Diff

View File

@@ -20,21 +20,13 @@ export class EdgeManager {
const activatedTargets: string[] = []
const edgesToDeactivate: Array<{ target: string; handle?: string }> = []
// First pass: categorize edges as activating or deactivating
// Don't modify incomingEdges yet - we need the original state for deactivation checks
for (const [edgeId, edge] of node.outgoingEdges) {
for (const [, edge] of node.outgoingEdges) {
if (skipBackwardsEdge && this.isBackwardsEdge(edge.sourceHandle)) {
continue
}
const shouldActivate = this.shouldActivateEdge(edge, output)
if (!shouldActivate) {
const isLoopEdge =
edge.sourceHandle === EDGE.LOOP_CONTINUE ||
edge.sourceHandle === EDGE.LOOP_CONTINUE_ALT ||
edge.sourceHandle === EDGE.LOOP_EXIT
if (!isLoopEdge) {
if (!this.shouldActivateEdge(edge, output)) {
if (!this.isLoopEdge(edge.sourceHandle)) {
edgesToDeactivate.push({ target: edge.target, handle: edge.sourceHandle })
}
continue
@@ -43,13 +35,19 @@ export class EdgeManager {
activatedTargets.push(edge.target)
}
// Second pass: process deactivations while incomingEdges is still intact
// This ensures hasActiveIncomingEdges can find all potential sources
const cascadeTargets = new Set<string>()
for (const { target, handle } of edgesToDeactivate) {
this.deactivateEdgeAndDescendants(node.id, target, handle)
this.deactivateEdgeAndDescendants(node.id, target, handle, cascadeTargets)
}
if (activatedTargets.length === 0) {
for (const { target } of edgesToDeactivate) {
if (this.isTerminalControlNode(target)) {
cascadeTargets.add(target)
}
}
}
// Third pass: update incomingEdges for activated targets
for (const targetId of activatedTargets) {
const targetNode = this.dag.nodes.get(targetId)
if (!targetNode) {
@@ -59,28 +57,25 @@ export class EdgeManager {
targetNode.incomingEdges.delete(node.id)
}
// Fourth pass: check readiness after all edge processing is complete
for (const targetId of activatedTargets) {
const targetNode = this.dag.nodes.get(targetId)
if (targetNode && this.isNodeReady(targetNode)) {
if (this.isTargetReady(targetId)) {
readyNodes.push(targetId)
}
}
for (const targetId of cascadeTargets) {
if (!readyNodes.includes(targetId) && !activatedTargets.includes(targetId)) {
if (this.isTargetReady(targetId)) {
readyNodes.push(targetId)
}
}
}
return readyNodes
}
isNodeReady(node: DAGNode): boolean {
if (node.incomingEdges.size === 0) {
return true
}
const activeIncomingCount = this.countActiveIncomingEdges(node)
if (activeIncomingCount > 0) {
return false
}
return true
return node.incomingEdges.size === 0 || this.countActiveIncomingEdges(node) === 0
}
restoreIncomingEdge(targetNodeId: string, sourceNodeId: string): void {
@@ -99,13 +94,10 @@ export class EdgeManager {
/**
* Clear deactivated edges for a set of nodes (used when restoring loop state for next iteration).
* This ensures error/success edges can be re-evaluated on each iteration.
*/
clearDeactivatedEdgesForNodes(nodeIds: Set<string>): void {
const edgesToRemove: string[] = []
for (const edgeKey of this.deactivatedEdges) {
// Edge key format is "sourceId-targetId-handle"
// Check if either source or target is in the nodeIds set
for (const nodeId of nodeIds) {
if (edgeKey.startsWith(`${nodeId}-`) || edgeKey.includes(`-${nodeId}-`)) {
edgesToRemove.push(edgeKey)
@@ -118,6 +110,44 @@ export class EdgeManager {
}
}
private isTargetReady(targetId: string): boolean {
const targetNode = this.dag.nodes.get(targetId)
return targetNode ? this.isNodeReady(targetNode) : false
}
private isLoopEdge(handle?: string): boolean {
return (
handle === EDGE.LOOP_CONTINUE ||
handle === EDGE.LOOP_CONTINUE_ALT ||
handle === EDGE.LOOP_EXIT
)
}
private isControlEdge(handle?: string): boolean {
return (
handle === EDGE.LOOP_CONTINUE ||
handle === EDGE.LOOP_CONTINUE_ALT ||
handle === EDGE.LOOP_EXIT ||
handle === EDGE.PARALLEL_EXIT
)
}
private isBackwardsEdge(sourceHandle?: string): boolean {
return sourceHandle === EDGE.LOOP_CONTINUE || sourceHandle === EDGE.LOOP_CONTINUE_ALT
}
private isTerminalControlNode(nodeId: string): boolean {
const node = this.dag.nodes.get(nodeId)
if (!node || node.outgoingEdges.size === 0) return false
for (const [, edge] of node.outgoingEdges) {
if (!this.isControlEdge(edge.sourceHandle)) {
return false
}
}
return true
}
private shouldActivateEdge(edge: DAGEdge, output: NormalizedBlockOutput): boolean {
const handle = edge.sourceHandle
@@ -159,14 +189,12 @@ export class EdgeManager {
}
}
private isBackwardsEdge(sourceHandle?: string): boolean {
return sourceHandle === EDGE.LOOP_CONTINUE || sourceHandle === EDGE.LOOP_CONTINUE_ALT
}
private deactivateEdgeAndDescendants(
sourceId: string,
targetId: string,
sourceHandle?: string
sourceHandle?: string,
cascadeTargets?: Set<string>,
isCascade = false
): void {
const edgeKey = this.createEdgeKey(sourceId, targetId, sourceHandle)
if (this.deactivatedEdges.has(edgeKey)) {
@@ -174,38 +202,46 @@ export class EdgeManager {
}
this.deactivatedEdges.add(edgeKey)
const targetNode = this.dag.nodes.get(targetId)
if (!targetNode) return
// Check if target has other active incoming edges
// Pass the specific edge key being deactivated, not just source ID,
// to handle multiple edges from same source to same target (e.g., condition branches)
const hasOtherActiveIncoming = this.hasActiveIncomingEdges(targetNode, edgeKey)
if (!hasOtherActiveIncoming) {
for (const [_, outgoingEdge] of targetNode.outgoingEdges) {
this.deactivateEdgeAndDescendants(targetId, outgoingEdge.target, outgoingEdge.sourceHandle)
if (isCascade && this.isTerminalControlNode(targetId)) {
cascadeTargets?.add(targetId)
}
if (this.hasActiveIncomingEdges(targetNode, edgeKey)) {
return
}
for (const [, outgoingEdge] of targetNode.outgoingEdges) {
if (!this.isControlEdge(outgoingEdge.sourceHandle)) {
this.deactivateEdgeAndDescendants(
targetId,
outgoingEdge.target,
outgoingEdge.sourceHandle,
cascadeTargets,
true
)
}
}
}
/**
* Checks if a node has any active incoming edges besides the one being excluded.
* This properly handles the case where multiple edges from the same source go to
* the same target (e.g., multiple condition branches pointing to one block).
*/
private hasActiveIncomingEdges(node: DAGNode, excludeEdgeKey: string): boolean {
for (const incomingSourceId of node.incomingEdges) {
const incomingNode = this.dag.nodes.get(incomingSourceId)
if (!incomingNode) continue
for (const [_, incomingEdge] of incomingNode.outgoingEdges) {
for (const [, incomingEdge] of incomingNode.outgoingEdges) {
if (incomingEdge.target === node.id) {
const incomingEdgeKey = this.createEdgeKey(
incomingSourceId,
node.id,
incomingEdge.sourceHandle
)
// Skip the specific edge being excluded, but check other edges from same source
if (incomingEdgeKey === excludeEdgeKey) continue
if (!this.deactivatedEdges.has(incomingEdgeKey)) {
return true

View File

@@ -554,6 +554,413 @@ describe('ExecutionEngine', () => {
})
})
describe('Error handling in execution', () => {
it('should fail execution when a single node throws an error', async () => {
const startNode = createMockNode('start', 'starter')
const errorNode = createMockNode('error-node', 'function')
startNode.outgoingEdges.set('edge1', { target: 'error-node' })
const dag = createMockDAG([startNode, errorNode])
const context = createMockContext()
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'start') return ['error-node']
return []
})
const nodeOrchestrator = {
executionCount: 0,
executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => {
if (nodeId === 'error-node') {
throw new Error('Block execution failed')
}
return { nodeId, output: {}, isFinalOutput: false }
}),
handleNodeCompletion: vi.fn(),
} as unknown as MockNodeOrchestrator
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
await expect(engine.run('start')).rejects.toThrow('Block execution failed')
})
it('should stop parallel branches when one branch throws an error', async () => {
const startNode = createMockNode('start', 'starter')
const parallelNodes = Array.from({ length: 5 }, (_, i) =>
createMockNode(`parallel${i}`, 'function')
)
parallelNodes.forEach((_, i) => {
startNode.outgoingEdges.set(`edge${i}`, { target: `parallel${i}` })
})
const dag = createMockDAG([startNode, ...parallelNodes])
const context = createMockContext()
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'start') return parallelNodes.map((_, i) => `parallel${i}`)
return []
})
const executedNodes: string[] = []
const nodeOrchestrator = {
executionCount: 0,
executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => {
executedNodes.push(nodeId)
if (nodeId === 'parallel0') {
await new Promise((resolve) => setTimeout(resolve, 10))
throw new Error('Parallel branch failed')
}
await new Promise((resolve) => setTimeout(resolve, 100))
return { nodeId, output: {}, isFinalOutput: false }
}),
handleNodeCompletion: vi.fn(),
} as unknown as MockNodeOrchestrator
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
await expect(engine.run('start')).rejects.toThrow('Parallel branch failed')
})
it('should capture only the first error when multiple parallel branches fail', async () => {
const startNode = createMockNode('start', 'starter')
const parallelNodes = Array.from({ length: 3 }, (_, i) =>
createMockNode(`parallel${i}`, 'function')
)
parallelNodes.forEach((_, i) => {
startNode.outgoingEdges.set(`edge${i}`, { target: `parallel${i}` })
})
const dag = createMockDAG([startNode, ...parallelNodes])
const context = createMockContext()
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'start') return parallelNodes.map((_, i) => `parallel${i}`)
return []
})
const nodeOrchestrator = {
executionCount: 0,
executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => {
if (nodeId === 'parallel0') {
await new Promise((resolve) => setTimeout(resolve, 10))
throw new Error('First error')
}
if (nodeId === 'parallel1') {
await new Promise((resolve) => setTimeout(resolve, 20))
throw new Error('Second error')
}
if (nodeId === 'parallel2') {
await new Promise((resolve) => setTimeout(resolve, 30))
throw new Error('Third error')
}
return { nodeId, output: {}, isFinalOutput: false }
}),
handleNodeCompletion: vi.fn(),
} as unknown as MockNodeOrchestrator
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
await expect(engine.run('start')).rejects.toThrow('First error')
})
it('should wait for ongoing executions to complete before throwing error', async () => {
const startNode = createMockNode('start', 'starter')
const fastErrorNode = createMockNode('fast-error', 'function')
const slowNode = createMockNode('slow', 'function')
startNode.outgoingEdges.set('edge1', { target: 'fast-error' })
startNode.outgoingEdges.set('edge2', { target: 'slow' })
const dag = createMockDAG([startNode, fastErrorNode, slowNode])
const context = createMockContext()
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'start') return ['fast-error', 'slow']
return []
})
let slowNodeCompleted = false
const nodeOrchestrator = {
executionCount: 0,
executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => {
if (nodeId === 'fast-error') {
await new Promise((resolve) => setTimeout(resolve, 10))
throw new Error('Fast error')
}
if (nodeId === 'slow') {
await new Promise((resolve) => setTimeout(resolve, 50))
slowNodeCompleted = true
return { nodeId, output: {}, isFinalOutput: false }
}
return { nodeId, output: {}, isFinalOutput: false }
}),
handleNodeCompletion: vi.fn(),
} as unknown as MockNodeOrchestrator
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
await expect(engine.run('start')).rejects.toThrow('Fast error')
expect(slowNodeCompleted).toBe(true)
})
it('should not queue new nodes after an error occurs', async () => {
const startNode = createMockNode('start', 'starter')
const errorNode = createMockNode('error-node', 'function')
const afterErrorNode = createMockNode('after-error', 'function')
startNode.outgoingEdges.set('edge1', { target: 'error-node' })
errorNode.outgoingEdges.set('edge2', { target: 'after-error' })
const dag = createMockDAG([startNode, errorNode, afterErrorNode])
const context = createMockContext()
const queuedNodes: string[] = []
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'start') {
queuedNodes.push('error-node')
return ['error-node']
}
if (node.id === 'error-node') {
queuedNodes.push('after-error')
return ['after-error']
}
return []
})
const executedNodes: string[] = []
const nodeOrchestrator = {
executionCount: 0,
executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => {
executedNodes.push(nodeId)
if (nodeId === 'error-node') {
throw new Error('Node error')
}
return { nodeId, output: {}, isFinalOutput: false }
}),
handleNodeCompletion: vi.fn(),
} as unknown as MockNodeOrchestrator
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
await expect(engine.run('start')).rejects.toThrow('Node error')
expect(executedNodes).not.toContain('after-error')
})
it('should populate error result with metadata when execution fails', async () => {
const startNode = createMockNode('start', 'starter')
const errorNode = createMockNode('error-node', 'function')
startNode.outgoingEdges.set('edge1', { target: 'error-node' })
const dag = createMockDAG([startNode, errorNode])
const context = createMockContext()
context.blockLogs.push({
blockId: 'start',
blockName: 'Start',
blockType: 'starter',
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
durationMs: 10,
success: true,
})
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'start') return ['error-node']
return []
})
const nodeOrchestrator = {
executionCount: 0,
executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => {
if (nodeId === 'error-node') {
const error = new Error('Execution failed') as any
error.executionResult = {
success: false,
output: { partial: 'data' },
logs: context.blockLogs,
metadata: context.metadata,
}
throw error
}
return { nodeId, output: {}, isFinalOutput: false }
}),
handleNodeCompletion: vi.fn(),
} as unknown as MockNodeOrchestrator
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
try {
await engine.run('start')
expect.fail('Should have thrown')
} catch (error: any) {
expect(error.executionResult).toBeDefined()
expect(error.executionResult.metadata.endTime).toBeDefined()
expect(error.executionResult.metadata.duration).toBeDefined()
}
})
it('should prefer cancellation status over error when both occur', async () => {
const abortController = new AbortController()
const startNode = createMockNode('start', 'starter')
const errorNode = createMockNode('error-node', 'function')
startNode.outgoingEdges.set('edge1', { target: 'error-node' })
const dag = createMockDAG([startNode, errorNode])
const context = createMockContext({ abortSignal: abortController.signal })
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'start') return ['error-node']
return []
})
const nodeOrchestrator = {
executionCount: 0,
executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => {
if (nodeId === 'error-node') {
abortController.abort()
throw new Error('Node error')
}
return { nodeId, output: {}, isFinalOutput: false }
}),
handleNodeCompletion: vi.fn(),
} as unknown as MockNodeOrchestrator
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('start')
expect(result.status).toBe('cancelled')
expect(result.success).toBe(false)
})
it('should stop loop iteration when error occurs in loop body', async () => {
const loopStartNode = createMockNode('loop-start', 'loop_sentinel')
loopStartNode.metadata = { isSentinel: true, sentinelType: 'start', loopId: 'loop1' }
const loopBodyNode = createMockNode('loop-body', 'function')
loopBodyNode.metadata = { isLoopNode: true, loopId: 'loop1' }
const loopEndNode = createMockNode('loop-end', 'loop_sentinel')
loopEndNode.metadata = { isSentinel: true, sentinelType: 'end', loopId: 'loop1' }
const afterLoopNode = createMockNode('after-loop', 'function')
loopStartNode.outgoingEdges.set('edge1', { target: 'loop-body' })
loopBodyNode.outgoingEdges.set('edge2', { target: 'loop-end' })
loopEndNode.outgoingEdges.set('loop_continue', {
target: 'loop-start',
sourceHandle: 'loop_continue',
})
loopEndNode.outgoingEdges.set('loop_complete', {
target: 'after-loop',
sourceHandle: 'loop_complete',
})
const dag = createMockDAG([loopStartNode, loopBodyNode, loopEndNode, afterLoopNode])
const context = createMockContext()
let iterationCount = 0
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'loop-start') return ['loop-body']
if (node.id === 'loop-body') return ['loop-end']
if (node.id === 'loop-end') {
iterationCount++
if (iterationCount < 5) return ['loop-start']
return ['after-loop']
}
return []
})
const nodeOrchestrator = {
executionCount: 0,
executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => {
if (nodeId === 'loop-body' && iterationCount >= 2) {
throw new Error('Loop body error on iteration 3')
}
return { nodeId, output: {}, isFinalOutput: false }
}),
handleNodeCompletion: vi.fn(),
} as unknown as MockNodeOrchestrator
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
await expect(engine.run('loop-start')).rejects.toThrow('Loop body error on iteration 3')
expect(iterationCount).toBeLessThanOrEqual(3)
})
it('should handle error that is not an Error instance', async () => {
const startNode = createMockNode('start', 'starter')
const errorNode = createMockNode('error-node', 'function')
startNode.outgoingEdges.set('edge1', { target: 'error-node' })
const dag = createMockDAG([startNode, errorNode])
const context = createMockContext()
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'start') return ['error-node']
return []
})
const nodeOrchestrator = {
executionCount: 0,
executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => {
if (nodeId === 'error-node') {
throw 'String error message'
}
return { nodeId, output: {}, isFinalOutput: false }
}),
handleNodeCompletion: vi.fn(),
} as unknown as MockNodeOrchestrator
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
await expect(engine.run('start')).rejects.toThrow('String error message')
})
it('should preserve partial output when error occurs after some blocks complete', async () => {
const startNode = createMockNode('start', 'starter')
const successNode = createMockNode('success', 'function')
const errorNode = createMockNode('error-node', 'function')
startNode.outgoingEdges.set('edge1', { target: 'success' })
successNode.outgoingEdges.set('edge2', { target: 'error-node' })
const dag = createMockDAG([startNode, successNode, errorNode])
const context = createMockContext()
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'start') return ['success']
if (node.id === 'success') return ['error-node']
return []
})
const nodeOrchestrator = {
executionCount: 0,
executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => {
if (nodeId === 'success') {
return { nodeId, output: { successData: 'preserved' }, isFinalOutput: false }
}
if (nodeId === 'error-node') {
throw new Error('Late error')
}
return { nodeId, output: {}, isFinalOutput: false }
}),
handleNodeCompletion: vi.fn(),
} as unknown as MockNodeOrchestrator
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
try {
await engine.run('start')
expect.fail('Should have thrown')
} catch (error: any) {
// Verify the error was thrown
expect(error.message).toBe('Late error')
// The partial output should be available in executionResult if attached
if (error.executionResult) {
expect(error.executionResult.output).toBeDefined()
}
}
})
})
describe('Cancellation flag behavior', () => {
it('should set cancelledFlag when abort signal fires', async () => {
const abortController = new AbortController()

View File

@@ -25,6 +25,8 @@ export class ExecutionEngine {
private pausedBlocks: Map<string, PauseMetadata> = new Map()
private allowResumeTriggers: boolean
private cancelledFlag = false
private errorFlag = false
private executionError: Error | null = null
private lastCancellationCheck = 0
private readonly useRedisCancellation: boolean
private readonly CANCELLATION_CHECK_INTERVAL_MS = 500
@@ -103,7 +105,7 @@ export class ExecutionEngine {
this.initializeQueue(triggerBlockId)
while (this.hasWork()) {
if (await this.checkCancellation()) {
if ((await this.checkCancellation()) || this.errorFlag) {
break
}
await this.processQueue()
@@ -113,6 +115,11 @@ export class ExecutionEngine {
await this.waitForAllExecutions()
}
// Rethrow the captured error so it's handled by the catch block
if (this.errorFlag && this.executionError) {
throw this.executionError
}
if (this.pausedBlocks.size > 0) {
return this.buildPausedResult(startTime)
}
@@ -196,11 +203,17 @@ export class ExecutionEngine {
}
private trackExecution(promise: Promise<void>): void {
this.executing.add(promise)
promise.catch(() => {})
promise.finally(() => {
this.executing.delete(promise)
})
const trackedPromise = promise
.catch((error) => {
if (!this.errorFlag) {
this.errorFlag = true
this.executionError = error instanceof Error ? error : new Error(String(error))
}
})
.finally(() => {
this.executing.delete(trackedPromise)
})
this.executing.add(trackedPromise)
}
private async waitForAnyExecution(): Promise<void> {
@@ -315,7 +328,7 @@ export class ExecutionEngine {
private async processQueue(): Promise<void> {
while (this.readyQueue.length > 0) {
if (await this.checkCancellation()) {
if ((await this.checkCancellation()) || this.errorFlag) {
break
}
const nodeId = this.dequeue()
@@ -324,7 +337,7 @@ export class ExecutionEngine {
this.trackExecution(promise)
}
if (this.executing.size > 0 && !this.cancelledFlag) {
if (this.executing.size > 0 && !this.cancelledFlag && !this.errorFlag) {
await this.waitForAnyExecution()
}
}

View File

@@ -305,7 +305,7 @@ export class AgentBlockHandler implements BlockHandler {
base.executeFunction = async (callParams: Record<string, any>) => {
const mergedParams = mergeToolParameters(userProvidedParams, callParams)
const { blockData, blockNameMapping } = collectBlockData(ctx)
const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx)
const result = await executeTool(
'function_execute',
@@ -317,6 +317,7 @@ export class AgentBlockHandler implements BlockHandler {
workflowVariables: ctx.workflowVariables || {},
blockData,
blockNameMapping,
blockOutputSchemas,
isCustomTool: true,
_context: {
workflowId: ctx.workflowId,
@@ -347,8 +348,8 @@ export class AgentBlockHandler implements BlockHandler {
): Promise<{ schema: any; code: string; title: string } | null> {
if (typeof window !== 'undefined') {
try {
const { useCustomToolsStore } = await import('@/stores/custom-tools')
const tool = useCustomToolsStore.getState().getTool(customToolId)
const { getCustomTool } = await import('@/hooks/queries/custom-tools')
const tool = getCustomTool(customToolId, ctx.workspaceId)
if (tool) {
return {
schema: tool.schema,
@@ -356,9 +357,9 @@ export class AgentBlockHandler implements BlockHandler {
title: tool.title,
}
}
logger.warn(`Custom tool not found in store: ${customToolId}`)
logger.warn(`Custom tool not found in cache: ${customToolId}`)
} catch (error) {
logger.error('Error accessing custom tools store:', { error })
logger.error('Error accessing custom tools cache:', { error })
}
}

View File

@@ -26,7 +26,7 @@ export async function evaluateConditionExpression(
const contextSetup = `const context = ${JSON.stringify(evalContext)};`
const code = `${contextSetup}\nreturn Boolean(${conditionExpression})`
const { blockData, blockNameMapping } = collectBlockData(ctx)
const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx)
const result = await executeTool(
'function_execute',
@@ -37,6 +37,7 @@ export async function evaluateConditionExpression(
workflowVariables: ctx.workflowVariables || {},
blockData,
blockNameMapping,
blockOutputSchemas,
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,

View File

@@ -75,7 +75,12 @@ describe('FunctionBlockHandler', () => {
workflowVariables: {},
blockData: {},
blockNameMapping: {},
_context: { workflowId: mockContext.workflowId, workspaceId: mockContext.workspaceId },
blockOutputSchemas: {},
_context: {
workflowId: mockContext.workflowId,
workspaceId: mockContext.workspaceId,
isDeployedContext: mockContext.isDeployedContext,
},
}
const expectedOutput: any = { result: 'Success' }
@@ -84,8 +89,8 @@ describe('FunctionBlockHandler', () => {
expect(mockExecuteTool).toHaveBeenCalledWith(
'function_execute',
expectedToolParams,
false, // skipPostProcess
mockContext // execution context
false,
mockContext
)
expect(result).toEqual(expectedOutput)
})
@@ -107,7 +112,12 @@ describe('FunctionBlockHandler', () => {
workflowVariables: {},
blockData: {},
blockNameMapping: {},
_context: { workflowId: mockContext.workflowId, workspaceId: mockContext.workspaceId },
blockOutputSchemas: {},
_context: {
workflowId: mockContext.workflowId,
workspaceId: mockContext.workspaceId,
isDeployedContext: mockContext.isDeployedContext,
},
}
const expectedOutput: any = { result: 'Success' }
@@ -116,8 +126,8 @@ describe('FunctionBlockHandler', () => {
expect(mockExecuteTool).toHaveBeenCalledWith(
'function_execute',
expectedToolParams,
false, // skipPostProcess
mockContext // execution context
false,
mockContext
)
expect(result).toEqual(expectedOutput)
})
@@ -132,7 +142,12 @@ describe('FunctionBlockHandler', () => {
workflowVariables: {},
blockData: {},
blockNameMapping: {},
_context: { workflowId: mockContext.workflowId, workspaceId: mockContext.workspaceId },
blockOutputSchemas: {},
_context: {
workflowId: mockContext.workflowId,
workspaceId: mockContext.workspaceId,
isDeployedContext: mockContext.isDeployedContext,
},
}
await handler.execute(mockContext, mockBlock, inputs)

View File

@@ -23,7 +23,7 @@ export class FunctionBlockHandler implements BlockHandler {
? inputs.code.map((c: { content: string }) => c.content).join('\n')
: inputs.code
const { blockData, blockNameMapping } = collectBlockData(ctx)
const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx)
const result = await executeTool(
'function_execute',
@@ -35,6 +35,7 @@ export class FunctionBlockHandler implements BlockHandler {
workflowVariables: ctx.workflowVariables || {},
blockData,
blockNameMapping,
blockOutputSchemas,
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,

View File

@@ -17,6 +17,7 @@ import {
} from '@/executor/human-in-the-loop/utils'
import type { BlockHandler, ExecutionContext, PauseMetadata } from '@/executor/types'
import { collectBlockData } from '@/executor/utils/block-data'
import { parseObjectStrings } from '@/executor/utils/json'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
@@ -265,7 +266,7 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {
if (dataMode === 'structured' && inputs.builderData) {
const convertedData = this.convertBuilderDataToJson(inputs.builderData)
return this.parseObjectStrings(convertedData)
return parseObjectStrings(convertedData)
}
return inputs.data || {}
@@ -485,29 +486,6 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {
)
}
private parseObjectStrings(data: any): any {
if (typeof data === 'string') {
try {
const parsed = JSON.parse(data)
if (typeof parsed === 'object' && parsed !== null) {
return this.parseObjectStrings(parsed)
}
return parsed
} catch {
return data
}
} else if (Array.isArray(data)) {
return data.map((item) => this.parseObjectStrings(item))
} else if (typeof data === 'object' && data !== null) {
const result: any = {}
for (const [key, value] of Object.entries(data)) {
result[key] = this.parseObjectStrings(value)
}
return result
}
return data
}
private parseStatus(status?: string): number {
if (!status) return HTTP.STATUS.OK
const parsed = Number(status)

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { BlockType, HTTP, REFERENCE } from '@/executor/constants'
import type { BlockHandler, ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
import { parseObjectStrings } from '@/executor/utils/json'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('ResponseBlockHandler')
@@ -73,7 +74,7 @@ export class ResponseBlockHandler implements BlockHandler {
if (dataMode === 'structured' && inputs.builderData) {
const convertedData = this.convertBuilderDataToJson(inputs.builderData)
return this.parseObjectStrings(convertedData)
return parseObjectStrings(convertedData)
}
return inputs.data || {}
@@ -222,29 +223,6 @@ export class ResponseBlockHandler implements BlockHandler {
)
}
private parseObjectStrings(data: any): any {
if (typeof data === 'string') {
try {
const parsed = JSON.parse(data)
if (typeof parsed === 'object' && parsed !== null) {
return this.parseObjectStrings(parsed)
}
return parsed
} catch {
return data
}
} else if (Array.isArray(data)) {
return data.map((item) => this.parseObjectStrings(item))
} else if (typeof data === 'object' && data !== null) {
const result: any = {}
for (const [key, value] of Object.entries(data)) {
result[key] = this.parseObjectStrings(value)
}
return result
}
return data
}
private parseStatus(status?: string): number {
if (!status) return HTTP.STATUS.OK
const parsed = Number(status)

View File

@@ -1,7 +1,7 @@
import '@sim/testing/mocks/executor'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { generateRouterPrompt } from '@/blocks/blocks/router'
import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router'
import { BlockType } from '@/executor/constants'
import { RouterBlockHandler } from '@/executor/handlers/router/router-handler'
import type { ExecutionContext } from '@/executor/types'
@@ -9,6 +9,7 @@ import { getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
const mockGenerateRouterPrompt = generateRouterPrompt as Mock
const mockGenerateRouterV2Prompt = generateRouterV2Prompt as Mock
const mockGetProviderFromModel = getProviderFromModel as Mock
const mockFetch = global.fetch as unknown as Mock
@@ -44,7 +45,7 @@ describe('RouterBlockHandler', () => {
metadata: { id: BlockType.ROUTER, name: 'Test Router' },
position: { x: 50, y: 50 },
config: { tool: BlockType.ROUTER, params: {} },
inputs: { prompt: 'string', model: 'string' }, // Using ParamType strings
inputs: { prompt: 'string', model: 'string' },
outputs: {},
enabled: true,
}
@@ -72,14 +73,11 @@ describe('RouterBlockHandler', () => {
workflow: mockWorkflow as SerializedWorkflow,
}
// Reset mocks using vi
vi.clearAllMocks()
// Default mock implementations
mockGetProviderFromModel.mockReturnValue('openai')
mockGenerateRouterPrompt.mockReturnValue('Generated System Prompt')
// Set up fetch mock to return a successful response
mockFetch.mockImplementation(() => {
return Promise.resolve({
ok: true,
@@ -147,7 +145,6 @@ describe('RouterBlockHandler', () => {
})
)
// Verify the request body contains the expected data
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody).toMatchObject({
@@ -180,7 +177,6 @@ describe('RouterBlockHandler', () => {
const inputs = { prompt: 'Test' }
mockContext.workflow!.blocks = [mockBlock, mockTargetBlock2]
// Expect execute to throw because getTargetBlocks (called internally) will throw
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'Target block target-block-1 not found'
)
@@ -190,7 +186,6 @@ describe('RouterBlockHandler', () => {
it('should throw error if LLM response is not a valid target block ID', async () => {
const inputs = { prompt: 'Test', apiKey: 'test-api-key' }
// Override fetch mock to return an invalid block ID
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
@@ -228,7 +223,6 @@ describe('RouterBlockHandler', () => {
it('should handle server error responses', async () => {
const inputs = { prompt: 'Test error handling.', apiKey: 'test-api-key' }
// Override fetch mock to return an error
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: false,
@@ -276,13 +270,12 @@ describe('RouterBlockHandler', () => {
mockGetProviderFromModel.mockReturnValue('vertex')
// Mock the database query for Vertex credential
const mockDb = await import('@sim/db')
const mockAccount = {
id: 'test-vertex-credential-id',
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
expiresAt: new Date(Date.now() + 3600000), // 1 hour from now
expiresAt: new Date(Date.now() + 3600000),
}
vi.spyOn(mockDb.db.query.account, 'findFirst').mockResolvedValue(mockAccount as any)
@@ -300,3 +293,287 @@ describe('RouterBlockHandler', () => {
expect(requestBody.apiKey).toBe('mock-access-token')
})
})
describe('RouterBlockHandler V2', () => {
let handler: RouterBlockHandler
let mockRouterV2Block: SerializedBlock
let mockContext: ExecutionContext
let mockWorkflow: Partial<SerializedWorkflow>
let mockTargetBlock1: SerializedBlock
let mockTargetBlock2: SerializedBlock
beforeEach(() => {
mockTargetBlock1 = {
id: 'target-block-1',
metadata: { id: 'agent', name: 'Support Agent' },
position: { x: 100, y: 100 },
config: { tool: 'agent', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
mockTargetBlock2 = {
id: 'target-block-2',
metadata: { id: 'agent', name: 'Sales Agent' },
position: { x: 100, y: 150 },
config: { tool: 'agent', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
mockRouterV2Block = {
id: 'router-v2-block-1',
metadata: { id: BlockType.ROUTER_V2, name: 'Test Router V2' },
position: { x: 50, y: 50 },
config: { tool: BlockType.ROUTER_V2, params: {} },
inputs: {},
outputs: {},
enabled: true,
}
mockWorkflow = {
blocks: [mockRouterV2Block, mockTargetBlock1, mockTargetBlock2],
connections: [
{
source: mockRouterV2Block.id,
target: mockTargetBlock1.id,
sourceHandle: 'router-route-support',
},
{
source: mockRouterV2Block.id,
target: mockTargetBlock2.id,
sourceHandle: 'router-route-sales',
},
],
}
handler = new RouterBlockHandler({})
mockContext = {
workflowId: 'test-workflow-id',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
completedLoops: new Set(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
workflow: mockWorkflow as SerializedWorkflow,
}
vi.clearAllMocks()
mockGetProviderFromModel.mockReturnValue('openai')
mockGenerateRouterV2Prompt.mockReturnValue('Generated V2 System Prompt')
})
it('should handle router_v2 blocks', () => {
expect(handler.canHandle(mockRouterV2Block)).toBe(true)
})
it('should execute router V2 and return reasoning', async () => {
const inputs = {
context: 'I need help with a billing issue',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: JSON.stringify([
{ id: 'route-support', title: 'Support', value: 'Customer support inquiries' },
{ id: 'route-sales', title: 'Sales', value: 'Sales and pricing questions' },
]),
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({
route: 'route-support',
reasoning: 'The user mentioned a billing issue which is a customer support matter.',
}),
model: 'gpt-4o',
tokens: { input: 150, output: 25, total: 175 },
}),
})
})
const result = await handler.execute(mockContext, mockRouterV2Block, inputs)
expect(result).toMatchObject({
context: 'I need help with a billing issue',
model: 'gpt-4o',
selectedRoute: 'route-support',
reasoning: 'The user mentioned a billing issue which is a customer support matter.',
selectedPath: {
blockId: 'target-block-1',
blockType: 'agent',
blockTitle: 'Support Agent',
},
})
})
it('should include responseFormat in provider request', async () => {
const inputs = {
context: 'Test context',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: JSON.stringify([{ id: 'route-1', title: 'Route 1', value: 'Description 1' }]),
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ route: 'route-1', reasoning: 'Test reasoning' }),
model: 'gpt-4o',
tokens: { input: 100, output: 20, total: 120 },
}),
})
})
await handler.execute(mockContext, mockRouterV2Block, inputs)
const fetchCallArgs = mockFetch.mock.calls[0]
const requestBody = JSON.parse(fetchCallArgs[1].body)
expect(requestBody.responseFormat).toEqual({
name: 'router_response',
schema: {
type: 'object',
properties: {
route: {
type: 'string',
description: 'The selected route ID or NO_MATCH',
},
reasoning: {
type: 'string',
description: 'Brief explanation of why this route was chosen',
},
},
required: ['route', 'reasoning'],
additionalProperties: false,
},
strict: true,
})
})
it('should handle NO_MATCH response with reasoning', async () => {
const inputs = {
context: 'Random unrelated query',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: JSON.stringify([{ id: 'route-1', title: 'Route 1', value: 'Specific topic' }]),
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({
route: 'NO_MATCH',
reasoning: 'The query does not relate to any available route.',
}),
model: 'gpt-4o',
tokens: { input: 100, output: 20, total: 120 },
}),
})
})
await expect(handler.execute(mockContext, mockRouterV2Block, inputs)).rejects.toThrow(
'Router could not determine a matching route: The query does not relate to any available route.'
)
})
it('should throw error for invalid route ID in response', async () => {
const inputs = {
context: 'Test context',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: JSON.stringify([{ id: 'route-1', title: 'Route 1', value: 'Description' }]),
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ route: 'invalid-route', reasoning: 'Some reasoning' }),
model: 'gpt-4o',
tokens: { input: 100, output: 20, total: 120 },
}),
})
})
await expect(handler.execute(mockContext, mockRouterV2Block, inputs)).rejects.toThrow(
/Router could not determine a valid route/
)
})
it('should handle routes passed as array instead of JSON string', async () => {
const inputs = {
context: 'Test context',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: [{ id: 'route-1', title: 'Route 1', value: 'Description' }],
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: JSON.stringify({ route: 'route-1', reasoning: 'Matched route 1' }),
model: 'gpt-4o',
tokens: { input: 100, output: 20, total: 120 },
}),
})
})
const result = await handler.execute(mockContext, mockRouterV2Block, inputs)
expect(result.selectedRoute).toBe('route-1')
expect(result.reasoning).toBe('Matched route 1')
})
it('should throw error when no routes are defined', async () => {
const inputs = {
context: 'Test context',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: '[]',
}
await expect(handler.execute(mockContext, mockRouterV2Block, inputs)).rejects.toThrow(
'No routes defined for router'
)
})
it('should handle fallback when JSON parsing fails', async () => {
const inputs = {
context: 'Test context',
model: 'gpt-4o',
apiKey: 'test-api-key',
routes: JSON.stringify([{ id: 'route-1', title: 'Route 1', value: 'Description' }]),
}
mockFetch.mockImplementationOnce(() => {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
content: 'route-1',
model: 'gpt-4o',
tokens: { input: 100, output: 5, total: 105 },
}),
})
})
const result = await handler.execute(mockContext, mockRouterV2Block, inputs)
expect(result.selectedRoute).toBe('route-1')
expect(result.reasoning).toBe('')
})
})

View File

@@ -238,6 +238,25 @@ export class RouterBlockHandler implements BlockHandler {
apiKey: finalApiKey,
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
responseFormat: {
name: 'router_response',
schema: {
type: 'object',
properties: {
route: {
type: 'string',
description: 'The selected route ID or NO_MATCH',
},
reasoning: {
type: 'string',
description: 'Brief explanation of why this route was chosen',
},
},
required: ['route', 'reasoning'],
additionalProperties: false,
},
strict: true,
},
}
if (providerId === 'vertex') {
@@ -277,16 +296,31 @@ export class RouterBlockHandler implements BlockHandler {
const result = await response.json()
const chosenRouteId = result.content.trim()
let chosenRouteId: string
let reasoning = ''
try {
const parsedResponse = JSON.parse(result.content)
chosenRouteId = parsedResponse.route?.trim() || ''
reasoning = parsedResponse.reasoning || ''
} catch (_parseError) {
logger.error('Router response was not valid JSON despite responseFormat', {
content: result.content,
})
chosenRouteId = result.content.trim()
}
if (chosenRouteId === 'NO_MATCH' || chosenRouteId.toUpperCase() === 'NO_MATCH') {
logger.info('Router determined no route matches the context, routing to error path')
throw new Error('Router could not determine a matching route for the given context')
throw new Error(
reasoning
? `Router could not determine a matching route: ${reasoning}`
: 'Router could not determine a matching route for the given context'
)
}
const chosenRoute = routes.find((r) => r.id === chosenRouteId)
// Throw error if LLM returns invalid route ID - this routes through error path
if (!chosenRoute) {
const availableRoutes = routes.map((r) => ({ id: r.id, title: r.title }))
logger.error(
@@ -298,7 +332,6 @@ export class RouterBlockHandler implements BlockHandler {
)
}
// Find the target block connected to this route's handle
const connection = ctx.workflow?.connections.find(
(conn) => conn.source === block.id && conn.sourceHandle === `router-${chosenRoute.id}`
)
@@ -334,6 +367,7 @@ export class RouterBlockHandler implements BlockHandler {
total: cost.total,
},
selectedRoute: chosenRoute.id,
reasoning,
selectedPath: targetBlock
? {
blockId: targetBlock.id,
@@ -353,7 +387,7 @@ export class RouterBlockHandler implements BlockHandler {
}
/**
* Parse routes from input (can be JSON string or array).
* Parse routes from input (can be JSON string or array)
*/
private parseRoutes(input: any): RouteDefinition[] {
try {

View File

@@ -204,26 +204,21 @@ describe('WorkflowBlockHandler', () => {
})
})
it('should map failed child output correctly', () => {
it('should throw error for failed child output so BlockExecutor can check error port', () => {
const childResult = {
success: false,
error: 'Child workflow failed',
}
const result = (handler as any).mapChildOutputToParent(
childResult,
'child-id',
'Child Workflow',
100
)
expect(() =>
(handler as any).mapChildOutputToParent(childResult, 'child-id', 'Child Workflow', 100)
).toThrow('Error in child workflow "Child Workflow": Child workflow failed')
expect(result).toEqual({
success: false,
childWorkflowName: 'Child Workflow',
result: {},
error: 'Child workflow failed',
childTraceSpans: [],
})
try {
;(handler as any).mapChildOutputToParent(childResult, 'child-id', 'Child Workflow', 100)
} catch (error: any) {
expect(error.childTraceSpans).toEqual([])
}
})
it('should handle nested response structures', () => {

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