fix(branding): align auth and deploy UI colors (#4669)

This commit is contained in:
Waleed
2026-05-19 16:18:07 -07:00
committed by GitHub
parent d7d58cec36
commit 972ec5f216
29 changed files with 292 additions and 2118 deletions
@@ -4,3 +4,7 @@ export const AUTH_PRIMARY_CTA_BASE =
/** Full-width variant used for primary auth form submit buttons. */
export const AUTH_SUBMIT_BTN = `${AUTH_PRIMARY_CTA_BASE} w-full` as const
/** Shared className for inline auth action links on dark auth surfaces. */
export const AUTH_TEXT_LINK =
'font-medium text-[var(--brand-accent)] underline-offset-4 transition hover:text-[var(--brand-accent-hover)] hover:underline disabled:cursor-not-allowed disabled:opacity-50' as const
+4 -13
View File
@@ -36,7 +36,6 @@
--shadow-overlay: 0 16px 48px rgba(0, 0, 0, 0.15);
--shadow-kbd: 0 4px 0 0 rgba(48, 48, 48, 1);
--shadow-kbd-sm: 0 2px 0 0 rgba(48, 48, 48, 1);
--shadow-brand-inset: inset 0 1.25px 2.5px 0 #9b77ff;
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04);
}
@@ -762,12 +761,8 @@ input[type="search"]::-ms-clear {
--card-text: 0 0% 3.9%;
--card-hover: 0 0% 96.1%;
--base-muted-foreground: #737373;
--gradient-primary: 263 85% 70%;
--gradient-secondary: 336 95% 65%;
--brand: #6f3dfa;
--brand-hover: #6338d9;
--brand-link: #6f3dfa;
--brand-link-hover: #6f3dfa;
--brand: #33c482;
--brand-hover: #2dac72;
}
.dark {
@@ -799,12 +794,8 @@ input[type="search"]::-ms-clear {
--card-text: 0 0% 98%;
--card-hover: 0 0% 12.0%;
--base-muted-foreground: #a3a3a3;
--gradient-primary: 263 90% 75%;
--gradient-secondary: 336 100% 72%;
--brand: #701ffc;
--brand-hover: #802fff;
--brand-link: #9d54ff;
--brand-link-hover: #a66fff;
--brand: #33c482;
--brand-hover: #2dac72;
}
}
@@ -7,7 +7,7 @@ import { Input, InputOTP, InputOTPGroup, InputOTPSlot, Label, Loader } from '@/c
import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import AuthBackground from '@/app/(auth)/components/auth-background'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
import { AUTH_SUBMIT_BTN, AUTH_TEXT_LINK } from '@/app/(auth)/components/auth-button-classes'
import { SupportFooter } from '@/app/(auth)/components/support-footer'
import Navbar from '@/app/(landing)/components/navbar/navbar'
import { useChatEmailOtpRequest, useChatEmailOtpVerify } from '@/hooks/queries/chats'
@@ -123,7 +123,7 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {
<div className='w-full max-w-lg px-4'>
<div className='flex flex-col items-center justify-center'>
<div className='space-y-1 text-center'>
<h1 className='text-balance font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
<h1 className='text-balance font-[430] font-season text-[40px] text-[var(--landing-text)] leading-[110%] tracking-[-0.02em]'>
{showOtpVerification ? 'Verify Your Email' : 'Email Verification'}
</h1>
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-lg leading-[125%] tracking-[0.02em]'>
@@ -159,11 +159,11 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {
className={cn(
showEmailValidationError &&
emailErrors.length > 0 &&
'border-red-500 focus:border-red-500'
'border-[var(--text-error)] focus:border-[var(--text-error)]'
)}
/>
{showEmailValidationError && emailErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
<div className='mt-1 space-y-1 text-[var(--text-error)] text-xs'>
{emailErrors.map((error) => (
<p key={error}>{error}</p>
))}
@@ -211,7 +211,7 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {
<InputOTPSlot
key={index}
index={index}
className={cn(authError && 'border-red-500')}
className={cn(authError && 'border-[var(--text-error)]')}
/>
))}
</InputOTPGroup>
@@ -219,7 +219,7 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {
</div>
{authError && (
<div className='mt-1 space-y-1 text-center text-red-400 text-xs'>
<div className='mt-1 space-y-1 text-center text-[var(--text-error)] text-xs'>
<p>{authError}</p>
</div>
)}
@@ -251,7 +251,7 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {
</span>
) : (
<button
className='font-medium text-[var(--brand-link)] underline-offset-4 transition hover:text-[var(--brand-link-hover)] hover:underline'
className={AUTH_TEXT_LINK}
onClick={handleResendOtp}
disabled={verifyOtp.isPending || requestOtp.isPending}
>
@@ -268,7 +268,7 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {
setOtpValue('')
setAuthError(null)
}}
className='font-medium text-[var(--brand-link)] underline-offset-4 transition hover:text-[var(--brand-link-hover)] hover:underline'
className={AUTH_TEXT_LINK}
>
Change email
</button>
@@ -7,7 +7,7 @@ import { Input, InputOTP, InputOTPGroup, InputOTPSlot, Label, Loader } from '@/c
import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import AuthBackground from '@/app/(auth)/components/auth-background'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
import { AUTH_SUBMIT_BTN, AUTH_TEXT_LINK } from '@/app/(auth)/components/auth-button-classes'
import { SupportFooter } from '@/app/(auth)/components/support-footer'
import Navbar from '@/app/(landing)/components/navbar/navbar'
import { useFormEmailOtpRequest, useFormEmailOtpVerify } from '@/hooks/queries/forms'
@@ -120,7 +120,7 @@ export function EmailAuth({ identifier, onAuthenticated }: EmailAuthProps) {
<div className='w-full max-w-lg px-4'>
<div className='flex flex-col items-center justify-center'>
<div className='space-y-1 text-center'>
<h1 className='text-balance font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
<h1 className='text-balance font-[430] font-season text-[40px] text-[var(--landing-text)] leading-[110%] tracking-[-0.02em]'>
{showOtpVerification ? 'Verify Your Email' : 'Email Verification'}
</h1>
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-lg leading-[125%] tracking-[0.02em]'>
@@ -154,11 +154,11 @@ export function EmailAuth({ identifier, onAuthenticated }: EmailAuthProps) {
className={cn(
showEmailValidationError &&
emailErrors.length > 0 &&
'border-red-500 focus:border-red-500'
'border-[var(--text-error)] focus:border-[var(--text-error)]'
)}
/>
{showEmailValidationError && emailErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
<div className='mt-1 space-y-1 text-[var(--text-error)] text-xs'>
{emailErrors.map((error) => (
<p key={error}>{error}</p>
))}
@@ -206,7 +206,7 @@ export function EmailAuth({ identifier, onAuthenticated }: EmailAuthProps) {
<InputOTPSlot
key={index}
index={index}
className={cn(authError && 'border-red-500')}
className={cn(authError && 'border-[var(--text-error)]')}
/>
))}
</InputOTPGroup>
@@ -214,7 +214,7 @@ export function EmailAuth({ identifier, onAuthenticated }: EmailAuthProps) {
</div>
{authError && (
<div className='mt-1 space-y-1 text-center text-red-400 text-xs'>
<div className='mt-1 space-y-1 text-center text-[var(--text-error)] text-xs'>
<p>{authError}</p>
</div>
)}
@@ -248,7 +248,7 @@ export function EmailAuth({ identifier, onAuthenticated }: EmailAuthProps) {
) : (
<button
type='button'
className='font-medium text-[var(--brand-link)] underline-offset-4 transition hover:text-[var(--brand-link-hover)] hover:underline'
className={AUTH_TEXT_LINK}
onClick={handleResendOtp}
disabled={verifyOtp.isPending || requestOtp.isPending}
>
@@ -266,7 +266,7 @@ export function EmailAuth({ identifier, onAuthenticated }: EmailAuthProps) {
setOtpValue('')
setAuthError(null)
}}
className='font-medium text-[var(--brand-link)] underline-offset-4 transition hover:text-[var(--brand-link-hover)] hover:underline'
className={AUTH_TEXT_LINK}
>
Change email
</button>
+1 -1
View File
@@ -18,7 +18,7 @@ export default function manifest(): MetadataRoute.Manifest {
scope: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: brand.theme?.primaryColor || '#6F3DFA',
theme_color: brand.theme?.primaryColor || '#33C482',
orientation: 'portrait-primary',
icons: [
{
@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { Check, Clipboard } from 'lucide-react'
@@ -81,7 +81,6 @@ interface A2aDeployProps {
workflowNeedsRedeployment?: boolean
onSubmittingChange?: (submitting: boolean) => void
onCanSaveChange?: (canSave: boolean) => void
/** Callback for when republish status changes - depends on local form state */
onNeedsRepublishChange?: (needsRepublish: boolean) => void
onDeployWorkflow?: () => Promise<void>
}
@@ -147,13 +146,12 @@ export function A2aDeploy({
return missing
}, [startBlockId, startBlockInputFormat])
const handleAddA2AInputs = useCallback(() => {
const handleAddA2AInputs = () => {
if (!startBlockId) return
const normalizedExisting = normalizeInputFormatValue(startBlockInputFormat)
const newFields: InputFormatField[] = []
// Add input field if missing (for TextPart)
if (missingFields.input) {
newFields.push({
id: generateId(),
@@ -164,7 +162,6 @@ export function A2aDeploy({
})
}
// Add data field if missing (for DataPart)
if (missingFields.data) {
newFields.push({
id: generateId(),
@@ -175,7 +172,6 @@ export function A2aDeploy({
})
}
// Add files field if missing (for FilePart)
if (missingFields.files) {
newFields.push({
id: generateId(),
@@ -193,7 +189,7 @@ export function A2aDeploy({
`Added A2A input fields to Start block: ${newFields.map((f) => f.name).join(', ')}`
)
}
}, [startBlockId, startBlockInputFormat, missingFields, collaborativeSetSubblockValue])
}
const [name, setName] = useState('')
const [description, setDescription] = useState('')
@@ -258,10 +254,7 @@ export function A2aDeploy({
workflowName,
])
const hasWorkflowChanges = useMemo(() => {
if (!existingAgent) return false
return !!workflowNeedsRedeployment
}, [existingAgent, workflowNeedsRedeployment])
const hasWorkflowChanges = existingAgent ? !!workflowNeedsRedeployment : false
const needsRepublish = existingAgent && (hasFormChanges || hasWorkflowChanges)
@@ -284,7 +277,7 @@ export function A2aDeploy({
onSubmittingChange?.(isSubmitting)
}, [isSubmitting, onSubmittingChange])
const handleCreateOrUpdate = useCallback(async () => {
const handleCreateOrUpdate = async () => {
const capabilities: AgentCapabilities = {
streaming: true,
pushNotifications: pushNotificationsEnabled,
@@ -319,20 +312,9 @@ export function A2aDeploy({
} catch (error) {
logger.error('Failed to save A2A agent:', error)
}
}, [
existingAgent,
name,
description,
pushNotificationsEnabled,
authScheme,
skillTags,
workspaceId,
workflowId,
createAgent,
updateAgent,
])
}
const handlePublish = useCallback(async () => {
const handlePublish = async () => {
if (!existingAgent) return
try {
await publishAgent.mutateAsync({
@@ -343,9 +325,9 @@ export function A2aDeploy({
} catch (error) {
logger.error('Failed to publish A2A agent:', error)
}
}, [existingAgent, workspaceId, publishAgent])
}
const handleUnpublish = useCallback(async () => {
const handleUnpublish = async () => {
if (!existingAgent) return
try {
await publishAgent.mutateAsync({
@@ -356,9 +338,9 @@ export function A2aDeploy({
} catch (error) {
logger.error('Failed to unpublish A2A agent:', error)
}
}, [existingAgent, workspaceId, publishAgent])
}
const handleDelete = useCallback(async () => {
const handleDelete = async () => {
if (!existingAgent) return
try {
await deleteAgent.mutateAsync({
@@ -370,9 +352,9 @@ export function A2aDeploy({
} catch (error) {
logger.error('Failed to delete A2A agent:', error)
}
}, [existingAgent, workspaceId, deleteAgent, workflowName, workflowDescription])
}
const handlePublishNewAgent = useCallback(async () => {
const handlePublishNewAgent = async () => {
const capabilities: AgentCapabilities = {
streaming: true,
pushNotifications: pushNotificationsEnabled,
@@ -406,21 +388,9 @@ export function A2aDeploy({
} catch (error) {
logger.error('Failed to publish A2A agent:', error)
}
}, [
name,
description,
pushNotificationsEnabled,
authScheme,
skillTags,
workspaceId,
workflowId,
createAgent,
publishAgent,
isDeployed,
onDeployWorkflow,
])
}
const handleUpdateAndRepublish = useCallback(async () => {
const handleUpdateAndRepublish = async () => {
if (!existingAgent) return
const capabilities: AgentCapabilities = {
@@ -455,20 +425,7 @@ export function A2aDeploy({
} catch (error) {
logger.error('Failed to update and republish A2A agent:', error)
}
}, [
existingAgent,
isDeployed,
workflowNeedsRedeployment,
onDeployWorkflow,
name,
description,
pushNotificationsEnabled,
authScheme,
skillTags,
workspaceId,
updateAgent,
publishAgent,
])
}
const baseUrl = getBaseUrl()
const endpoint = existingAgent ? `${baseUrl}/api/a2a/serve/${existingAgent.id}` : null
@@ -484,7 +441,7 @@ export function A2aDeploy({
)
}, [startBlockInputFormat])
const getExampleInputData = useCallback((): Record<string, unknown> => {
const getExampleInputData = (): Record<string, unknown> => {
const data: Record<string, unknown> = {}
for (const field of additionalInputFields) {
switch (field.type) {
@@ -508,13 +465,12 @@ export function A2aDeploy({
}
}
return data
}, [additionalInputFields])
}
const getJsonRpcPayload = useCallback((): Record<string, unknown> => {
const getJsonRpcPayload = (): Record<string, unknown> => {
const inputData = getExampleInputData()
const hasAdditionalData = Object.keys(inputData).length > 0
// Build parts array: TextPart for message text, DataPart for additional fields
const parts: Array<Record<string, unknown>> = [{ kind: 'text', text: 'Hello, agent!' }]
if (hasAdditionalData) {
parts.push({ kind: 'data', data: inputData })
@@ -531,9 +487,9 @@ export function A2aDeploy({
},
},
}
}, [getExampleInputData, useStreamingExample])
}
const getCurlCommand = useCallback((): string => {
const getCurlCommand = (): string => {
if (!endpoint) return ''
const payload = getJsonRpcPayload()
const requiresAuth = authScheme !== 'none'
@@ -623,13 +579,13 @@ console.log(data);`
default:
return ''
}
}, [endpoint, language, getJsonRpcPayload, authScheme])
}
const handleCopyCommand = useCallback(() => {
const handleCopyCommand = () => {
navigator.clipboard.writeText(getCurlCommand())
setCodeCopied(true)
setTimeout(() => setCodeCopied(false), 2000)
}, [getCurlCommand])
}
if (isLoading) {
return (
@@ -664,7 +620,6 @@ console.log(data);`
}}
className='-mx-1 space-y-3 overflow-y-auto px-1 pb-4'
>
{/* Endpoint URL (shown when agent exists) */}
{existingAgent && endpoint && (
<div>
<div className='mb-[6.5px] flex items-center justify-between'>
@@ -692,15 +647,15 @@ console.log(data);`
</Tooltip.Content>
</Tooltip.Root>
</div>
<div className='relative flex items-stretch overflow-hidden rounded-sm border border-[var(--border-1)]'>
<div className='flex items-center whitespace-nowrap bg-[var(--surface-5)] pr-1.5 pl-2 font-medium text-[var(--text-secondary)] text-sm dark:bg-[var(--surface-5)]'>
<div className='relative flex items-stretch overflow-hidden rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)]'>
<div className='flex items-center whitespace-nowrap bg-[var(--surface-5)] pr-1.5 pl-2 font-medium text-[var(--text-secondary)] text-sm'>
{baseUrl.replace(/^https?:\/\//, '')}/api/a2a/serve/
</div>
<div className='relative flex-1'>
<Input
value={existingAgent.id}
readOnly
className='rounded-none border-0 pl-0 text-[var(--text-tertiary)] shadow-none'
className='rounded-none border-0 bg-transparent pl-0 text-[var(--text-tertiary)] shadow-none'
/>
</div>
</div>
@@ -710,13 +665,12 @@ console.log(data);`
</div>
)}
{/* Agent Name */}
<div>
<Label
htmlFor='a2a-name'
className='mb-[6.5px] block pl-0.5 font-medium text-[var(--text-primary)] text-small'
>
Agent name <span className='text-red-500'>*</span>
Agent name <span className='text-[var(--text-error)]'>*</span>
</Label>
<Input
id='a2a-name'
@@ -730,13 +684,12 @@ console.log(data);`
</p>
</div>
{/* Description */}
<div>
<Label
htmlFor='a2a-description'
className='mb-[6.5px] block pl-0.5 font-medium text-[var(--text-primary)] text-small'
>
Description <span className='text-red-500'>*</span>
Description <span className='text-[var(--text-error)]'>*</span>
</Label>
<Textarea
id='a2a-description'
@@ -748,7 +701,6 @@ console.log(data);`
/>
</div>
{/* Access */}
<div>
<Label className='mb-[6.5px] block pl-0.5 font-medium text-[var(--text-primary)] text-small'>
Access
@@ -767,7 +719,6 @@ console.log(data);`
</p>
</div>
{/* Capabilities */}
<div>
<Label className='mb-[6.5px] block pl-0.5 font-medium text-[var(--text-primary)] text-small'>
Capabilities
@@ -786,7 +737,6 @@ console.log(data);`
</div>
</div>
{/* Tags */}
<div>
<Label className='mb-[6.5px] block pl-0.5 font-medium text-[var(--text-primary)] text-small'>
Tags
@@ -810,7 +760,6 @@ console.log(data);`
/>
</div>
{/* Curl Preview (shown when agent exists) */}
{existingAgent && endpoint && (
<>
<div>
@@ -879,21 +828,21 @@ console.log(data);`
<code className='text-micro'>&lt;start.files&gt;</code>.
</p>
{missingFields.any && (
<button
<Button
type='button'
className='flex flex-none cursor-pointer items-center whitespace-nowrap rounded-md border border-[var(--border-1)] bg-[var(--surface-5)] px-[9px] py-0.5 font-medium font-sans text-[var(--text-primary)] text-caption hover-hover:bg-[var(--surface-active)]'
variant='default'
className='flex-none whitespace-nowrap px-[9px] py-0.5 text-caption'
title='Add required A2A input fields to Start block'
onClick={handleAddA2AInputs}
>
<span className='whitespace-nowrap'>Add inputs</span>
</button>
</Button>
)}
</div>
</div>
</>
)}
{/* Hidden triggers for modal footer */}
<button type='submit' data-a2a-save-trigger className='hidden' />
<button type='button' data-a2a-publish-trigger className='hidden' onClick={handlePublish} />
<button
@@ -38,7 +38,6 @@ type AsyncExampleType = 'execute' | 'status' | 'rate-limits'
type CodeLanguage = 'curl' | 'python' | 'javascript' | 'typescript'
type CopiedState = {
endpoint: boolean // @remark: not used
sync: boolean
stream: boolean
async: boolean
@@ -70,7 +69,6 @@ export function ApiDeploy({
const [asyncExampleType, setAsyncExampleType] = useState<AsyncExampleType>('execute')
const [language, setLanguage] = useState<CodeLanguage>('curl')
const [copied, setCopied] = useState<CopiedState>({
endpoint: false, // @remark: not used
sync: false,
stream: false,
async: false,
@@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { AlertTriangle, Check, Clipboard, Eye, EyeOff, RefreshCw } from 'lucide-react'
import {
Button,
@@ -219,7 +220,6 @@ export function ChatDeploy({
const isNewChat = !existingChat?.id
// Open window before async operation to avoid popup blockers
const newTab = isNewChat ? window.open('', '_blank') : null
try {
@@ -265,15 +265,16 @@ export function ChatDeploy({
newTab.close()
}
await onRefetchChat()
hasInitializedFormRef.current = false
await onRefetchChat()
setFormInitCounter((c) => c + 1)
} catch (error: any) {
} catch (error: unknown) {
const message = getErrorMessage(error)
newTab?.close()
if (error.message?.includes('identifier')) {
setError('identifier', error.message)
if (message.includes('identifier')) {
setError('identifier', message)
} else {
setError('general', error.message)
setError('general', message)
}
} finally {
setChatSubmitting(false)
@@ -295,9 +296,9 @@ export function ChatDeploy({
await onRefetchChat()
onDeploymentComplete?.()
} catch (error: any) {
} catch (error: unknown) {
logger.error('Failed to delete chat:', error)
setError('general', error.message || 'An unexpected error occurred while deleting')
setError('general', getErrorMessage(error) || 'An unexpected error occurred while deleting')
} finally {
setShowDeleteConfirmation(false)
}
@@ -316,7 +317,7 @@ export function ChatDeploy({
className='-mx-1 space-y-4 overflow-y-auto px-1'
>
{errors.general && (
<div className='flex items-center gap-2 rounded-md border border-red-500/20 bg-red-500/10 px-3 py-2 text-red-400 text-small'>
<div className='flex items-center gap-2 rounded-md border border-[color-mix(in_srgb,var(--text-error)_20%,transparent)] bg-[color-mix(in_srgb,var(--text-error)_10%,transparent)] px-3 py-2 text-[var(--text-error)] text-small'>
<AlertTriangle className='size-4 flex-shrink-0' />
<span>{errors.general}</span>
</div>
@@ -347,7 +348,9 @@ export function ChatDeploy({
required
disabled={chatSubmitting}
/>
{errors.title && <p className='mt-1 text-destructive text-sm'>{errors.title}</p>}
{errors.title && (
<p className='mt-[6.5px] text-[var(--text-error)] text-caption'>{errors.title}</p>
)}
</div>
<div>
@@ -362,7 +365,9 @@ export function ChatDeploy({
disabled={chatSubmitting}
/>
{errors.outputBlocks && (
<p className='mt-1 text-destructive text-sm'>{errors.outputBlocks}</p>
<p className='mt-[6.5px] text-[var(--text-error)] text-caption'>
{errors.outputBlocks}
</p>
)}
</div>
@@ -403,7 +408,7 @@ export function ChatDeploy({
type='button'
data-delete-trigger
onClick={() => setShowDeleteConfirmation(true)}
style={{ display: 'none' }}
className='hidden'
/>
</div>
</form>
@@ -528,11 +533,11 @@ function IdentifierInput({
</Label>
<div
className={cn(
'relative flex items-stretch overflow-hidden rounded-sm border border-[var(--border-1)]',
'relative flex items-stretch overflow-hidden rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)]',
error && 'border-[var(--text-error)]'
)}
>
<div className='flex items-center whitespace-nowrap bg-[var(--surface-5)] pr-1.5 pl-2 font-medium text-[var(--text-secondary)] text-sm dark:bg-[var(--surface-5)]'>
<div className='flex items-center whitespace-nowrap bg-[var(--surface-5)] pr-1.5 pl-2 font-medium text-[var(--text-secondary)] text-sm'>
{getDomainPrefix()}
</div>
<div className='relative flex-1'>
@@ -544,7 +549,7 @@ function IdentifierInput({
required
disabled={disabled}
className={cn(
'rounded-none border-0 pl-0 shadow-none disabled:bg-transparent disabled:opacity-100',
'rounded-none border-0 bg-transparent pl-0 shadow-none disabled:bg-transparent disabled:opacity-100',
(isChecking || (isValid && value)) && 'pr-8'
)}
/>
@@ -625,9 +630,7 @@ function AuthSelector({
const [showPassword, setShowPassword] = useState(false)
const [emailError, setEmailError] = useState('')
const [copySuccess, setCopySuccess] = useState(false)
const [emailItems, setEmailItems] = useState<TagItem[]>(() =>
emails.map((email) => ({ value: email, isValid: true }))
)
const [invalidEmailItems, setInvalidEmailItems] = useState<TagItem[]>([])
useEffect(() => {
if (!copySuccess) return
@@ -653,25 +656,36 @@ function AuthSelector({
const validation = quickValidateEmail(normalized)
const isValid = validation.isValid || isDomainPattern
if (emailItems.some((item) => item.value === normalized)) {
if (
emails.includes(normalized) ||
invalidEmailItems.some((item) => item.value === normalized)
) {
return false
}
setEmailItems((prev) => [...prev, { value: normalized, isValid }])
if (isValid) {
setEmailError('')
onEmailsChange([...emails, normalized])
} else {
setInvalidEmailItems((prev) => [...prev, { value: normalized, isValid }])
}
return isValid
}
const handleRemoveEmailItem = (_value: string, index: number, isValid: boolean) => {
const emailItems = [
...emails.map((email) => ({ value: email, isValid: true })),
...invalidEmailItems,
]
const handleRemoveEmailItem = (_value: string, index: number) => {
const itemToRemove = emailItems[index]
setEmailItems((prev) => prev.filter((_, i) => i !== index))
if (isValid && itemToRemove) {
if (!itemToRemove) return
if (itemToRemove.isValid) {
onEmailsChange(emails.filter((e) => e !== itemToRemove.value))
} else {
setInvalidEmailItems((prev) => prev.filter((item) => item.value !== itemToRemove.value))
}
}
@@ -1,61 +0,0 @@
'use client'
import { useCallback, useState } from 'react'
import { Check, Clipboard } from 'lucide-react'
import { Button, Code, Label, Tooltip } from '@/components/emcn'
interface EmbedCodeGeneratorProps {
formUrl: string
identifier: string
}
export function EmbedCodeGenerator({ formUrl }: EmbedCodeGeneratorProps) {
const [copied, setCopied] = useState(false)
const iframeCode = `<iframe
src="${formUrl}"
width="100%"
height="600"
frameborder="0"
style="border: none; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);"
></iframe>`
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(iframeCode)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}, [iframeCode])
return (
<div className='pb-3'>
<div className='mb-[6.5px] flex items-center justify-between'>
<Label className='block pl-0.5 font-medium text-[var(--text-primary)] text-small'>
Embed Code
</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={handleCopy}
aria-label='Copy code'
className='!p-1.5 -my-1.5'
>
{copied ? <Check className='size-3' /> : <Clipboard className='size-3' />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>{copied ? 'Copied' : 'Copy'}</span>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Code.Viewer
code={iframeCode}
language='javascript'
wrapText
className='!min-h-0 rounded-sm border border-[var(--border-1)]'
/>
</div>
)
}
@@ -1,356 +0,0 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { GripVertical } from 'lucide-react'
import { Switch } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
/** Maximum character limits for form fields */
const MAX_LENGTHS = {
FORM_TITLE: 80,
FORM_DESCRIPTION: 250,
FIELD_LABEL: 50,
FIELD_DESCRIPTION: 200,
} as const
interface CharacterCounterProps {
current: number
max: number
className?: string
}
/**
* Displays a character count indicator (X/Max).
*/
function CharacterCounter({ current, max, className }: CharacterCounterProps) {
const isNearLimit = current >= max * 0.9
const isAtLimit = current >= max
return (
<span
className={cn(
'text-micro tabular-nums transition-colors',
isAtLimit
? 'text-[var(--text-error)]'
: isNearLimit
? 'text-[var(--text-warning)]'
: 'text-[var(--text-muted)]',
className
)}
>
{current}/{max}
</span>
)
}
interface FieldConfig {
name: string
type: string
label: string
description?: string
required?: boolean
}
interface FormBuilderProps {
title: string
onTitleChange: (title: string) => void
description: string
onDescriptionChange: (description: string) => void
fieldConfigs: FieldConfig[]
onFieldConfigsChange: (configs: FieldConfig[]) => void
titleError?: string
}
/**
* Interactive form builder preview component.
* Displays an editable form preview with drag-and-drop reorderable fields.
*/
export function FormBuilder({
title,
onTitleChange,
description,
onDescriptionChange,
fieldConfigs,
onFieldConfigsChange,
titleError,
}: FormBuilderProps) {
const [draggedIndex, setDraggedIndex] = useState<number | null>(null)
const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null)
const [focusedInput, setFocusedInput] = useState<string | null>(null)
const dragImageRef = useRef<HTMLDivElement | null>(null)
const containerRef = useRef<HTMLDivElement | null>(null)
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
const handleDragStart = useCallback((e: React.DragEvent, index: number) => {
setDraggedIndex(index)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', String(index))
// Get the parent item element for the drag image
const itemElement = itemRefs.current[index]
if (!itemElement) return
// Create a custom drag image with background
const clone = itemElement.cloneNode(true) as HTMLDivElement
clone.style.position = 'absolute'
clone.style.top = '-9999px'
clone.style.left = '-9999px'
clone.style.width = `${itemElement.offsetWidth}px`
clone.style.backgroundColor = 'var(--surface-1)'
clone.style.borderRadius = '4px'
clone.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.15)'
clone.style.opacity = '0.95'
clone.style.border = '1px solid var(--border-1)'
document.body.appendChild(clone)
dragImageRef.current = clone
// Offset to align with the grip handle (10px padding + 6px grip padding)
e.dataTransfer.setDragImage(clone, 16, itemElement.offsetHeight / 2)
// Cleanup after drag starts
requestAnimationFrame(() => {
if (dragImageRef.current) {
document.body.removeChild(dragImageRef.current)
dragImageRef.current = null
}
})
}, [])
const handleDragEnd = useCallback(() => {
// Perform the reorder if we have valid indices
if (draggedIndex !== null && dropTargetIndex !== null && draggedIndex !== dropTargetIndex) {
const newConfigs = [...fieldConfigs]
const [draggedItem] = newConfigs.splice(draggedIndex, 1)
// Adjust target index if dragging down
const adjustedTarget = dropTargetIndex > draggedIndex ? dropTargetIndex - 1 : dropTargetIndex
newConfigs.splice(adjustedTarget, 0, draggedItem)
onFieldConfigsChange(newConfigs)
}
setDraggedIndex(null)
setDropTargetIndex(null)
if (dragImageRef.current) {
document.body.removeChild(dragImageRef.current)
dragImageRef.current = null
}
}, [draggedIndex, dropTargetIndex, fieldConfigs, onFieldConfigsChange])
const handleContainerDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
if (draggedIndex === null) return
const containerRect = containerRef.current?.getBoundingClientRect()
if (!containerRect) return
const y = e.clientY
// Find the drop position based on cursor Y
let newDropIndex = fieldConfigs.length
for (let i = 0; i < itemRefs.current.length; i++) {
const item = itemRefs.current[i]
if (!item) continue
const rect = item.getBoundingClientRect()
const midpoint = rect.top + rect.height / 2
if (y < midpoint) {
newDropIndex = i
break
}
}
// Don't allow dropping at same position or adjacent (no change)
if (newDropIndex !== dropTargetIndex) {
setDropTargetIndex(newDropIndex)
}
},
[draggedIndex, dropTargetIndex, fieldConfigs.length]
)
const handleContainerDragLeave = useCallback((e: React.DragEvent) => {
const relatedTarget = e.relatedTarget as HTMLElement | null
if (!relatedTarget || !containerRef.current?.contains(relatedTarget)) {
setDropTargetIndex(null)
}
}, [])
const updateFieldConfig = useCallback(
(index: number, updates: Partial<FieldConfig>) => {
const newConfigs = fieldConfigs.map((config, i) =>
i === index ? { ...config, ...updates } : config
)
onFieldConfigsChange(newConfigs)
},
[fieldConfigs, onFieldConfigsChange]
)
return (
<div className='overflow-hidden rounded-sm border border-[var(--border-1)]'>
{/* Header */}
<div className='border-[var(--border-1)] border-b px-2.5 py-2'>
<div className='flex items-center gap-1.5'>
<input
type='text'
value={title}
onChange={(e) => {
if (e.target.value.length <= MAX_LENGTHS.FORM_TITLE) {
onTitleChange(e.target.value)
}
}}
onFocus={() => setFocusedInput('title')}
onBlur={() => setFocusedInput(null)}
placeholder='Form Title'
maxLength={MAX_LENGTHS.FORM_TITLE}
className={cn(
'min-w-0 flex-1 bg-transparent font-medium text-[var(--text-primary)] text-sm outline-none',
'placeholder:text-[var(--text-muted)]',
titleError && 'text-[var(--text-error)]'
)}
/>
{focusedInput === 'title' && (
<CharacterCounter current={title.length} max={MAX_LENGTHS.FORM_TITLE} />
)}
</div>
{titleError && <p className='mt-1 text-[var(--text-error)] text-caption'>{titleError}</p>}
<div className='mt-1 flex items-center gap-1.5'>
<input
type='text'
value={description}
onChange={(e) => {
if (e.target.value.length <= MAX_LENGTHS.FORM_DESCRIPTION) {
onDescriptionChange(e.target.value)
}
}}
onFocus={() => setFocusedInput('description')}
onBlur={() => setFocusedInput(null)}
placeholder='Description'
maxLength={MAX_LENGTHS.FORM_DESCRIPTION}
className='min-w-0 flex-1 bg-transparent text-[var(--text-secondary)] text-caption outline-none placeholder:text-[var(--text-muted)]'
/>
{focusedInput === 'description' && (
<CharacterCounter current={description.length} max={MAX_LENGTHS.FORM_DESCRIPTION} />
)}
</div>
</div>
{/* Fields */}
<div
ref={containerRef}
className='relative'
onDragOver={handleContainerDragOver}
onDragLeave={handleContainerDragLeave}
>
{fieldConfigs.map((config, index) => {
const showIndicatorAbove =
dropTargetIndex === index && draggedIndex !== null && draggedIndex !== index
return (
<div
key={config.name}
ref={(el) => {
itemRefs.current[index] = el
}}
className={cn(
'relative border-[var(--border-1)] border-b px-2.5 py-2 last:border-b-0',
draggedIndex === index && 'opacity-40'
)}
>
{/* Drop indicator line - shown above this item */}
{showIndicatorAbove && (
<div className='-translate-y-1/2 pointer-events-none absolute top-0 right-0 left-0 z-20 h-[2px] bg-[var(--brand-accent)]'>
<div className='-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-0 size-[6px] rounded-full bg-[var(--brand-accent)]' />
<div className='-translate-y-1/2 absolute top-1/2 right-0 size-[6px] translate-x-1/2 rounded-full bg-[var(--brand-accent)]' />
</div>
)}
{/* Label row */}
<div className='flex items-center gap-1.5'>
{/* Grip handle - larger hit area with negative margins */}
<div
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragEnd={handleDragEnd}
className='-my-1.5 -ml-1.5 flex cursor-grab items-center py-1.5 pl-1.5 text-[var(--text-muted)] hover-hover:text-[var(--text-tertiary)] active:cursor-grabbing'
>
<GripVertical className='size-[12px]' />
</div>
<input
type='text'
value={config.label}
onChange={(e) => {
if (e.target.value.length <= MAX_LENGTHS.FIELD_LABEL) {
updateFieldConfig(index, { label: e.target.value })
}
}}
onFocus={() => setFocusedInput(`field-label-${index}`)}
onBlur={() => setFocusedInput(null)}
placeholder='Label'
maxLength={MAX_LENGTHS.FIELD_LABEL}
className='min-w-0 flex-1 bg-transparent font-medium text-[var(--text-primary)] text-small outline-none placeholder:text-[var(--text-muted)]'
/>
{focusedInput === `field-label-${index}` && (
<CharacterCounter current={config.label.length} max={MAX_LENGTHS.FIELD_LABEL} />
)}
<div className='flex items-center gap-1.5'>
<span className='text-[var(--text-tertiary)] text-xs'>Required</span>
<Switch
checked={config.required ?? false}
onCheckedChange={(checked) => updateFieldConfig(index, { required: checked })}
/>
</div>
</div>
{/* Help text */}
<div className='mt-1 flex items-center gap-1.5 pl-4.5'>
<input
type='text'
value={config.description || ''}
onChange={(e) => {
if (e.target.value.length <= MAX_LENGTHS.FIELD_DESCRIPTION) {
updateFieldConfig(index, { description: e.target.value })
}
}}
onFocus={() => setFocusedInput(`field-desc-${index}`)}
onBlur={() => setFocusedInput(null)}
placeholder='Description...'
maxLength={MAX_LENGTHS.FIELD_DESCRIPTION}
className='min-w-0 flex-1 bg-transparent text-[var(--text-secondary)] text-xs outline-none placeholder:text-[var(--text-muted)]'
/>
{focusedInput === `field-desc-${index}` && (
<CharacterCounter
current={(config.description || '').length}
max={MAX_LENGTHS.FIELD_DESCRIPTION}
/>
)}
</div>
{/* Field mapping */}
<div className='mt-1.5 ml-4.5 flex items-center justify-between border-[var(--border-1)] border-t border-dashed pt-1.5'>
<span className='text-[var(--text-muted)] text-micro'>
{config.name ? (
<>
maps to <code>{config.name}</code>
</>
) : null}
</span>
<span className='text-[var(--text-muted)] text-micro'>{config.type}</span>
</div>
</div>
)
})}
{/* Drop indicator at the end */}
{dropTargetIndex === fieldConfigs.length && draggedIndex !== null && (
<div className='pointer-events-none absolute right-0 bottom-0 left-0 z-20 h-[2px] translate-y-1/2 bg-[var(--brand-accent)]'>
<div className='-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-0 size-[6px] rounded-full bg-[var(--brand-accent)]' />
<div className='-translate-y-1/2 absolute top-1/2 right-0 size-[6px] translate-x-1/2 rounded-full bg-[var(--brand-accent)]' />
</div>
)}
</div>
</div>
)
}
@@ -1,563 +0,0 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { Check, Eye, EyeOff } from 'lucide-react'
import {
ButtonGroup,
ButtonGroupItem,
Input,
Label,
Loader,
Skeleton,
TagInput,
type TagItem,
Textarea,
Tooltip,
} from '@/components/emcn'
import { isDev } from '@/lib/core/config/feature-flags'
import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl, getEmailDomain } from '@/lib/core/utils/urls'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import {
type FieldConfig,
useCreateForm,
useDeleteForm,
useFormByWorkflow,
useUpdateForm,
} from '@/hooks/queries/forms'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { EmbedCodeGenerator } from './components/embed-code-generator'
import { FormBuilder } from './components/form-builder'
import { useIdentifierValidation } from './hooks/use-identifier-validation'
const logger = createLogger('FormDeploy')
interface FormErrors {
identifier?: string
title?: string
password?: string
emails?: string
general?: string
}
interface FormDeployProps {
workflowId: string
onDeploymentComplete?: () => void
onValidationChange?: (isValid: boolean) => void
onSubmittingChange?: (isSubmitting: boolean) => void
formSubmitting?: boolean
setFormSubmitting?: (submitting: boolean) => void
onDeployed?: () => Promise<void>
}
const getDomainPrefix = (() => {
const prefix = `${getEmailDomain()}/form/`
return () => prefix
})()
export function FormDeploy({
workflowId,
onDeploymentComplete,
onValidationChange,
onSubmittingChange,
formSubmitting,
setFormSubmitting,
onDeployed,
}: FormDeployProps) {
const [identifier, setIdentifier] = useState('')
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [thankYouMessage, setThankYouMessage] = useState(
'Your response has been submitted successfully.'
)
const [authType, setAuthType] = useState<'public' | 'password' | 'email'>('public')
const [password, setPassword] = useState('')
const [emailItems, setEmailItems] = useState<TagItem[]>([])
const [formUrl, setFormUrl] = useState('')
const [inputFields, setInputFields] = useState<{ name: string; type: string }[]>([])
const [showPasswordField, setShowPasswordField] = useState(false)
const [fieldConfigs, setFieldConfigs] = useState<FieldConfig[]>([])
const fieldConfigsInitializedRef = useRef(false)
const [errors, setErrors] = useState<FormErrors>({})
const { data: existingForm, isLoading } = useFormByWorkflow(workflowId)
const createFormMutation = useCreateForm()
const updateFormMutation = useUpdateForm()
const deleteFormMutation = useDeleteForm()
const isSubmitting = createFormMutation.isPending || updateFormMutation.isPending
const {
isChecking: isCheckingIdentifier,
error: identifierError,
isValid: isIdentifierValid,
} = useIdentifierValidation(identifier, existingForm?.identifier, !!existingForm)
const setError = (field: keyof FormErrors, message: string) => {
setErrors((prev) => ({ ...prev, [field]: message }))
}
const clearError = (field: keyof FormErrors) => {
setErrors((prev) => ({ ...prev, [field]: undefined }))
}
// Populate form fields when existing form data is loaded
useEffect(() => {
if (existingForm) {
setIdentifier(existingForm.identifier)
setTitle(existingForm.title)
setDescription(existingForm.description || '')
setThankYouMessage(
existingForm.customizations?.thankYouMessage ||
'Your response has been submitted successfully.'
)
setAuthType(existingForm.authType)
setEmailItems(
(existingForm.allowedEmails || []).map((email) => ({ value: email, isValid: true }))
)
if (existingForm.customizations?.fieldConfigs) {
setFieldConfigs(existingForm.customizations.fieldConfigs)
}
const baseUrl = getBaseUrl()
try {
const url = new URL(baseUrl)
let host = url.host
if (host.startsWith('www.')) host = host.substring(4)
setFormUrl(`${url.protocol}//${host}/form/${existingForm.identifier}`)
} catch {
setFormUrl(
isDev
? `http://localhost:3000/form/${existingForm.identifier}`
: `https://sim.ai/form/${existingForm.identifier}`
)
}
} else if (!isLoading) {
const workflowName =
useWorkflowStore.getState().blocks[Object.keys(useWorkflowStore.getState().blocks)[0]]
?.name || 'Form'
setTitle(`${workflowName} Form`)
}
}, [existingForm, isLoading])
useEffect(() => {
fieldConfigsInitializedRef.current = false
}, [workflowId])
useEffect(() => {
const blocks = Object.values(useWorkflowStore.getState().blocks)
const startBlock = blocks.find((b) => isInputDefinitionTrigger(b.type))
if (startBlock) {
const inputFormat = useSubBlockStore.getState().getValue(startBlock.id, 'inputFormat')
if (inputFormat && Array.isArray(inputFormat)) {
setInputFields(inputFormat)
if (!fieldConfigsInitializedRef.current) {
fieldConfigsInitializedRef.current = true
setFieldConfigs(
inputFormat.map((f: { name: string; type?: string }) => ({
name: f.name,
type: f.type || 'string',
label: f.name
.replace(/([A-Z])/g, ' $1')
.replace(/_/g, ' ')
.replace(/^./, (s) => s.toUpperCase())
.trim(),
}))
)
}
}
}
}, [workflowId])
const allowedEmails = emailItems.filter((item) => item.isValid).map((item) => item.value)
useEffect(() => {
const isValid =
inputFields.length > 0 &&
isIdentifierValid &&
title.trim().length > 0 &&
(authType !== 'password' || password.length > 0 || !!existingForm?.hasPassword) &&
(authType !== 'email' || allowedEmails.length > 0)
onValidationChange?.(isValid)
}, [
isIdentifierValid,
title,
authType,
password,
allowedEmails.length,
existingForm?.hasPassword,
onValidationChange,
inputFields.length,
])
useEffect(() => {
onSubmittingChange?.(isSubmitting)
setFormSubmitting?.(isSubmitting)
}, [isSubmitting, onSubmittingChange, setFormSubmitting])
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault()
setErrors({})
if (!isIdentifierValid && identifier !== existingForm?.identifier) {
setError('identifier', 'Please wait for identifier validation to complete')
return
}
if (!title.trim()) {
setError('title', 'Title is required')
return
}
if (authType === 'password' && !existingForm?.hasPassword && !password.trim()) {
setError('password', 'Password is required')
return
}
if (authType === 'email' && allowedEmails.length === 0) {
setError('emails', 'At least one email or domain is required')
return
}
const customizations = {
thankYouMessage,
fieldConfigs,
}
try {
if (existingForm) {
await updateFormMutation.mutateAsync({
formId: existingForm.id,
workflowId,
data: {
identifier,
title,
description,
customizations,
authType,
password: password || undefined,
allowedEmails,
},
})
} else {
const result = await createFormMutation.mutateAsync({
workflowId,
identifier,
title,
description,
customizations,
authType,
password,
allowedEmails,
})
if (result?.formUrl) {
setFormUrl(result.formUrl)
window.open(result.formUrl, '_blank', 'noopener,noreferrer')
}
}
await onDeployed?.()
if (!existingForm) {
onDeploymentComplete?.()
}
} catch (err: unknown) {
const message = getErrorMessage(err, 'An error occurred')
logger.error('Error deploying form:', err)
if (message.toLowerCase().includes('identifier')) {
setError('identifier', message)
} else if (message.toLowerCase().includes('password')) {
setError('password', message)
} else if (message.toLowerCase().includes('email')) {
setError('emails', message)
} else {
setError('general', message)
}
}
},
[
existingForm,
workflowId,
identifier,
title,
description,
thankYouMessage,
fieldConfigs,
authType,
password,
allowedEmails,
isIdentifierValid,
createFormMutation,
updateFormMutation,
onDeployed,
onDeploymentComplete,
]
)
const handleDelete = useCallback(async () => {
if (!existingForm) return
try {
await deleteFormMutation.mutateAsync({
formId: existingForm.id,
workflowId,
})
setIdentifier('')
setTitle('')
setDescription('')
setFormUrl('')
} catch (err) {
logger.error('Error deleting form:', err)
}
}, [existingForm, deleteFormMutation, workflowId])
if (isLoading) {
return (
<div className='-mx-1 space-y-4 px-1'>
<div className='space-y-3'>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[26px]' />
<Skeleton className='h-[34px] w-full rounded-sm' />
<Skeleton className='mt-[6.5px] h-[14px] w-[320px]' />
</div>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[30px]' />
<Skeleton className='h-[34px] w-full rounded-sm' />
</div>
</div>
</div>
)
}
if (inputFields.length === 0) {
return (
<div className='flex h-full items-center justify-center text-[var(--text-secondary)] text-small'>
Add input fields to the Start block to create a form.
</div>
)
}
const fullUrl = `${getBaseUrl()}/form/${identifier}`
const displayUrl = fullUrl.replace(/^https?:\/\//, '')
return (
<form id='form-deploy-form' onSubmit={handleSubmit} className='-mx-1 space-y-4 px-1'>
<div className='space-y-3'>
{/* URL Input - matching chat style */}
<div>
<Label className='mb-[6.5px] block pl-0.5 font-medium text-[var(--text-primary)] text-small'>
URL
</Label>
<div
className={cn(
'relative flex items-stretch overflow-hidden rounded-sm border border-[var(--border-1)]',
(identifierError || errors.identifier) && 'border-[var(--text-error)]'
)}
>
<div className='flex items-center whitespace-nowrap bg-[var(--surface-5)] pr-1.5 pl-2 font-medium text-[var(--text-secondary)] text-sm'>
{getDomainPrefix()}
</div>
<div className='relative flex-1'>
<Input
value={identifier}
onChange={(e) => {
setIdentifier(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))
clearError('identifier')
}}
placeholder='my-form'
className={cn(
'rounded-none border-0 pl-0 shadow-none',
(isCheckingIdentifier || (isIdentifierValid && identifier)) && 'pr-8'
)}
/>
{isCheckingIdentifier ? (
<div className='-translate-y-1/2 absolute top-1/2 right-2'>
<Loader className='size-4 text-[var(--text-tertiary)]' animate />
</div>
) : (
isIdentifierValid &&
identifier && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='-translate-y-1/2 absolute top-1/2 right-2'>
<Check className='size-4 text-[var(--brand-accent)]' />
</div>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Name is available</span>
</Tooltip.Content>
</Tooltip.Root>
)
)}
</div>
</div>
{(identifierError || errors.identifier) && (
<p className='mt-[6.5px] text-[var(--text-error)] text-caption'>
{identifierError || errors.identifier}
</p>
)}
<p className='mt-[6.5px] truncate text-[var(--text-secondary)] text-xs'>
{existingForm && identifier ? (
<>
Live at:{' '}
<a
href={fullUrl}
target='_blank'
rel='noopener noreferrer'
className='text-[var(--text-primary)] hover-hover:underline'
>
{displayUrl}
</a>
</>
) : (
'The unique URL path where your form will be accessible'
)}
</p>
</div>
{/* Form Builder Preview */}
<div>
<Label className='mb-[6.5px] block pl-0.5 font-medium text-[var(--text-primary)] text-small'>
Form builder
</Label>
<FormBuilder
title={title}
onTitleChange={(value: string) => {
setTitle(value)
clearError('title')
}}
description={description}
onDescriptionChange={setDescription}
fieldConfigs={fieldConfigs}
onFieldConfigsChange={setFieldConfigs}
titleError={errors.title}
/>
</div>
{/* Access Control */}
<div>
<Label className='mb-[6.5px] block pl-0.5 font-medium text-[var(--text-primary)] text-small'>
Access control
</Label>
<ButtonGroup
value={authType}
onValueChange={(val) => setAuthType(val as 'public' | 'password' | 'email')}
>
<ButtonGroupItem value='public'>Public</ButtonGroupItem>
<ButtonGroupItem value='password'>Password</ButtonGroupItem>
<ButtonGroupItem value='email'>Email</ButtonGroupItem>
</ButtonGroup>
</div>
{authType === 'password' && (
<div>
<Label className='mb-[6.5px] block pl-0.5 font-medium text-[var(--text-primary)] text-small'>
Password
</Label>
<div className='relative'>
<Input
type={showPasswordField ? 'text' : 'password'}
value={password}
onChange={(e) => {
setPassword(e.target.value)
clearError('password')
}}
placeholder={
existingForm?.hasPassword ? 'Enter new password to change' : 'Enter password'
}
className='pr-8'
/>
<button
type='button'
onClick={() => setShowPasswordField(!showPasswordField)}
className='-translate-y-1/2 absolute top-1/2 right-[8px] text-[var(--text-secondary)]'
>
{showPasswordField ? <EyeOff className='size-4' /> : <Eye className='size-4' />}
</button>
</div>
{errors.password && (
<p className='mt-[6.5px] text-[var(--text-error)] text-caption'>{errors.password}</p>
)}
<p className='mt-[6.5px] text-[var(--text-secondary)] text-xs'>
{existingForm?.hasPassword
? 'Leave empty to keep the current password'
: 'This password will be required to access your form'}
</p>
</div>
)}
{authType === 'email' && (
<div>
<Label className='mb-[6.5px] block pl-0.5 font-medium text-[var(--text-primary)] text-small'>
Allowed emails
</Label>
<TagInput
items={emailItems}
onAdd={(value) => {
const trimmed = value.trim().toLowerCase()
if (!trimmed || emailItems.some((item) => item.value === trimmed)) {
return false
}
const isDomainPattern = trimmed.startsWith('@')
const isValid = isDomainPattern || trimmed.includes('@')
setEmailItems((prev) => [...prev, { value: trimmed, isValid }])
if (isValid) {
clearError('emails')
}
return isValid
}}
onRemove={(_value, index) => {
setEmailItems((prev) => prev.filter((_, i) => i !== index))
}}
placeholder='Enter emails or @domain.com'
placeholderWithTags='Add another'
/>
{errors.emails && (
<p className='mt-[6.5px] text-[var(--text-error)] text-caption'>{errors.emails}</p>
)}
<p className='mt-[6.5px] text-[var(--text-secondary)] text-xs'>
Add specific emails or entire domains (@example.com)
</p>
</div>
)}
{/* Thank You Message */}
<div>
<Label className='mb-[6.5px] block pl-0.5 font-medium text-[var(--text-primary)] text-small'>
Thank you message
</Label>
<Textarea
value={thankYouMessage}
onChange={(e) => setThankYouMessage(e.target.value)}
placeholder='Your response has been submitted successfully.'
rows={2}
className='min-h-[60px] resize-none'
/>
<p className='mt-[6.5px] text-[var(--text-secondary)] text-xs'>
This message will be displayed after form submission
</p>
</div>
{/* Embed Code - only when deployed */}
{existingForm && formUrl && (
<EmbedCodeGenerator formUrl={formUrl} identifier={identifier} />
)}
{errors.general && (
<p className='mt-[6.5px] text-[var(--text-error)] text-caption'>{errors.general}</p>
)}
<button type='button' data-delete-trigger onClick={handleDelete} className='hidden' />
</div>
</form>
)
}
@@ -1,89 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { requestJson } from '@/lib/api/client/request'
import { validateFormIdentifierContract } from '@/lib/api/contracts/forms'
const IDENTIFIER_PATTERN = /^[a-z0-9-]+$/
const DEBOUNCE_MS = 500
interface IdentifierValidationState {
isChecking: boolean
error: string | null
isValid: boolean
}
/**
* Hook for validating form identifier availability with debounced API checks
* @param identifier - The identifier to validate
* @param originalIdentifier - The original identifier when editing an existing form
* @param isEditingExisting - Whether we're editing an existing form deployment
*/
export function useIdentifierValidation(
identifier: string,
originalIdentifier?: string,
isEditingExisting?: boolean
): IdentifierValidationState {
const [isChecking, setIsChecking] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isValid, setIsValid] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
setError(null)
setIsValid(false)
setIsChecking(false)
if (!identifier.trim()) {
return
}
if (originalIdentifier && identifier === originalIdentifier) {
setIsValid(true)
return
}
if (isEditingExisting && !originalIdentifier) {
setIsValid(true)
return
}
if (!IDENTIFIER_PATTERN.test(identifier)) {
setError('Identifier can only contain lowercase letters, numbers, and hyphens')
return
}
setIsChecking(true)
timeoutRef.current = setTimeout(async () => {
try {
const data = await requestJson(validateFormIdentifierContract, {
query: { identifier },
})
if (!data.available) {
setError(data.error || 'This identifier is already in use')
setIsValid(false)
} else {
setError(null)
setIsValid(true)
}
} catch {
setError('Error checking identifier availability')
setIsValid(false)
} finally {
setIsChecking(false)
}
}, DEBOUNCE_MS)
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [identifier, originalIdentifier, isEditingExisting])
return { isChecking, error, isValid }
}
@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { getErrorMessage } from '@sim/utils/errors'
import { useParams } from 'next/navigation'
import {
@@ -144,23 +144,23 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
}))
}
const handleCloseAttempt = useCallback(() => {
const handleCloseAttempt = () => {
if (hasChanges && !isSaving) {
setShowUnsavedChangesAlert(true)
} else {
onOpenChange(false)
}
}, [hasChanges, isSaving, onOpenChange])
}
const handleDiscardChanges = useCallback(() => {
const handleDiscardChanges = () => {
setShowUnsavedChangesAlert(false)
setDescription(initialDescriptionRef.current)
setParamDescriptions({ ...initialParamDescriptionsRef.current })
setAccessMode(initialAccessModeRef.current)
onOpenChange(false)
}, [onOpenChange])
}
const handleSave = useCallback(async () => {
const handleSave = async () => {
if (!workflowId) return
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
@@ -201,18 +201,7 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
} finally {
setIsSaving(false)
}
}, [
workflowId,
workspaceId,
description,
workflowMetadata,
starterBlockId,
inputFormat,
paramDescriptions,
setValue,
onOpenChange,
accessMode,
])
}
return (
<>
@@ -1,6 +1,6 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { useRef, useState } from 'react'
import {
Button,
Modal,
@@ -43,7 +43,7 @@ export function VersionDescriptionModal({
const hasChanges = description.trim() !== initialDescriptionRef.current.trim()
const isGenerating = generateMutation.isPending
const handleCloseAttempt = useCallback(() => {
const handleCloseAttempt = () => {
if (updateMutation.isPending || isGenerating) {
return
}
@@ -52,15 +52,15 @@ export function VersionDescriptionModal({
} else {
onOpenChange(false)
}
}, [hasChanges, updateMutation.isPending, isGenerating, onOpenChange])
}
const handleDiscardChanges = useCallback(() => {
const handleDiscardChanges = () => {
setShowUnsavedChangesAlert(false)
setDescription(initialDescriptionRef.current)
onOpenChange(false)
}, [onOpenChange])
}
const handleGenerateDescription = useCallback(() => {
const handleGenerateDescription = () => {
generateMutation.mutate({
workflowId,
version,
@@ -68,9 +68,9 @@ export function VersionDescriptionModal({
setDescription(accumulated)
},
})
}, [workflowId, version, generateMutation])
}
const handleSave = useCallback(() => {
const handleSave = () => {
if (!workflowId) return
updateMutation.mutate(
@@ -85,7 +85,7 @@ export function VersionDescriptionModal({
},
}
)
}, [workflowId, version, description, updateMutation, onOpenChange])
}
return (
<>
@@ -2,10 +2,10 @@
import { useEffect, useRef, useState } from 'react'
import { formatDateTime } from '@sim/utils/formatting'
import clsx from 'clsx'
import { FileText, MoreVertical, Pencil, RotateCcw, SendToBack } from 'lucide-react'
import {
Button,
Input,
Popover,
PopoverContent,
PopoverItem,
@@ -13,6 +13,7 @@ import {
Skeleton,
Tooltip,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import { useUpdateDeploymentVersion } from '@/hooks/queries/deployments'
import { VersionDescriptionModal } from './version-description-modal'
@@ -99,9 +100,6 @@ export function Versions({
onSuccess: () => {
setEditingVersion(null)
},
onError: () => {
// Keep editing state open on error so user can retry
},
}
)
}
@@ -141,38 +139,34 @@ export function Versions({
return (
<div className='overflow-hidden rounded-sm border border-[var(--border)]'>
<div className='flex h-[30px] items-center bg-[var(--surface-1)] px-4'>
<div className={clsx(COLUMN_WIDTHS.VERSION, COLUMN_BASE_CLASS)}>
<div className={cn(COLUMN_WIDTHS.VERSION, COLUMN_BASE_CLASS)}>
<Skeleton className='h-[12px] w-[50px]' />
</div>
<div className={clsx(COLUMN_WIDTHS.DEPLOYED_BY, COLUMN_BASE_CLASS)}>
<div className={cn(COLUMN_WIDTHS.DEPLOYED_BY, COLUMN_BASE_CLASS)}>
<Skeleton className='h-[12px] w-[76px]' />
</div>
<div className={clsx(COLUMN_WIDTHS.TIMESTAMP, 'min-w-0')}>
<div className={cn(COLUMN_WIDTHS.TIMESTAMP, 'min-w-0')}>
<Skeleton className='h-[12px] w-[68px]' />
</div>
<div className={clsx(COLUMN_WIDTHS.ACTIONS, COLUMN_BASE_CLASS)} />
<div className={cn(COLUMN_WIDTHS.ACTIONS, COLUMN_BASE_CLASS)} />
</div>
<div className='bg-[var(--surface-2)]'>
{[0, 1].map((i) => (
<div key={i} className='flex h-[36px] items-center px-4'>
<div className={clsx(COLUMN_WIDTHS.VERSION, COLUMN_BASE_CLASS, 'min-w-0 pr-2')}>
<div className={cn(COLUMN_WIDTHS.VERSION, COLUMN_BASE_CLASS, 'min-w-0 pr-2')}>
<div className='flex items-center gap-4'>
<Skeleton className='size-[6px] rounded-xs' />
<Skeleton className='h-[12px] w-[60px]' />
</div>
</div>
<div className={clsx(COLUMN_WIDTHS.DEPLOYED_BY, COLUMN_BASE_CLASS, 'min-w-0')}>
<div className={cn(COLUMN_WIDTHS.DEPLOYED_BY, COLUMN_BASE_CLASS, 'min-w-0')}>
<Skeleton className='h-[12px] w-[80px]' />
</div>
<div className={clsx(COLUMN_WIDTHS.TIMESTAMP, 'min-w-0')}>
<div className={cn(COLUMN_WIDTHS.TIMESTAMP, 'min-w-0')}>
<Skeleton className='h-[12px] w-[160px]' />
</div>
<div
className={clsx(
COLUMN_WIDTHS.ACTIONS,
COLUMN_BASE_CLASS,
'flex justify-end gap-0.5'
)}
className={cn(COLUMN_WIDTHS.ACTIONS, COLUMN_BASE_CLASS, 'flex justify-end gap-0.5')}
>
<Skeleton className='size-[20px] rounded-sm' />
<Skeleton className='size-[20px] rounded-sm' />
@@ -195,16 +189,16 @@ export function Versions({
return (
<div className='overflow-hidden rounded-sm border border-[var(--border)]'>
<div className='flex h-[30px] items-center bg-[var(--surface-1)] px-4'>
<div className={clsx(COLUMN_WIDTHS.VERSION, COLUMN_BASE_CLASS)}>
<div className={cn(COLUMN_WIDTHS.VERSION, COLUMN_BASE_CLASS)}>
<span className={HEADER_TEXT_CLASS}>Version</span>
</div>
<div className={clsx(COLUMN_WIDTHS.DEPLOYED_BY, COLUMN_BASE_CLASS)}>
<div className={cn(COLUMN_WIDTHS.DEPLOYED_BY, COLUMN_BASE_CLASS)}>
<span className={HEADER_TEXT_CLASS}>Deployed by</span>
</div>
<div className={clsx(COLUMN_WIDTHS.TIMESTAMP, 'min-w-0')}>
<div className={cn(COLUMN_WIDTHS.TIMESTAMP, 'min-w-0')}>
<span className={HEADER_TEXT_CLASS}>Timestamp</span>
</div>
<div className={clsx(COLUMN_WIDTHS.ACTIONS, COLUMN_BASE_CLASS)} />
<div className={cn(COLUMN_WIDTHS.ACTIONS, COLUMN_BASE_CLASS)} />
</div>
<div className='bg-[var(--surface-2)]'>
@@ -216,7 +210,7 @@ export function Versions({
key={v.id}
role='button'
tabIndex={0}
className={clsx(
className={cn(
'flex h-[36px] cursor-pointer items-center px-4 transition-colors duration-100',
isSelected
? 'bg-[color-mix(in_srgb,var(--accent)_10%,transparent)] hover-hover:bg-[color-mix(in_srgb,var(--accent)_15%,transparent)]'
@@ -231,17 +225,17 @@ export function Versions({
}
}}
>
<div className={clsx(COLUMN_WIDTHS.VERSION, COLUMN_BASE_CLASS, 'min-w-0 pr-2')}>
<div className={cn(COLUMN_WIDTHS.VERSION, COLUMN_BASE_CLASS, 'min-w-0 pr-2')}>
<div className='flex items-center gap-4'>
<div
className={clsx(
'h-[6px] w-[6px] shrink-0 rounded-xs',
className={cn(
'size-[6px] shrink-0 rounded-xs',
v.isActive ? 'bg-[var(--indicator-active)]' : 'bg-[var(--indicator-inactive)]'
)}
title={v.isActive ? 'Live' : 'Inactive'}
/>
{editingVersion === v.version ? (
<input
<Input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
@@ -256,9 +250,8 @@ export function Versions({
}}
onClick={(e) => e.stopPropagation()}
onBlur={() => handleSaveRename(v.version)}
className={clsx(
'w-full border-0 bg-transparent p-0 font-medium text-caption leading-5 outline-none',
'text-[var(--text-primary)] focus:outline-none focus:ring-0'
className={cn(
'h-auto w-full border-0 bg-transparent p-0 font-medium text-[var(--text-primary)] text-caption leading-5 shadow-none outline-none focus:outline-none focus-visible:ring-0'
)}
maxLength={100}
disabled={renameMutation.isPending}
@@ -268,9 +261,7 @@ export function Versions({
spellCheck='false'
/>
) : (
<span
className={clsx('block flex items-center gap-1 truncate', ROW_TEXT_CLASS)}
>
<span className={cn('block flex items-center gap-1 truncate', ROW_TEXT_CLASS)}>
<span className='truncate'>{v.name || `v${v.version}`}</span>
{v.isActive && <span className='text-[var(--text-tertiary)]'> (live)</span>}
{isSelected && (
@@ -281,24 +272,20 @@ export function Versions({
</div>
</div>
<div className={clsx(COLUMN_WIDTHS.DEPLOYED_BY, COLUMN_BASE_CLASS, 'min-w-0')}>
<span
className={clsx('block truncate text-[var(--text-tertiary)]', ROW_TEXT_CLASS)}
>
<div className={cn(COLUMN_WIDTHS.DEPLOYED_BY, COLUMN_BASE_CLASS, 'min-w-0')}>
<span className={cn('block truncate text-[var(--text-tertiary)]', ROW_TEXT_CLASS)}>
{v.deployedBy || 'Unknown'}
</span>
</div>
<div className={clsx(COLUMN_WIDTHS.TIMESTAMP, 'min-w-0')}>
<span
className={clsx('block truncate text-[var(--text-tertiary)]', ROW_TEXT_CLASS)}
>
<div className={cn(COLUMN_WIDTHS.TIMESTAMP, 'min-w-0')}>
<span className={cn('block truncate text-[var(--text-tertiary)]', ROW_TEXT_CLASS)}>
{formatDateTime(new Date(v.createdAt))}
</span>
</div>
<div
className={clsx(
className={cn(
COLUMN_WIDTHS.ACTIONS,
COLUMN_BASE_CLASS,
'flex items-center justify-end gap-0.5'
@@ -308,7 +295,7 @@ export function Versions({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className={clsx(
className={cn(
'!p-1',
!v.description &&
'text-[var(--text-quaternary)] hover-hover:text-[var(--text-tertiary)]'
@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
Button,
@@ -17,6 +17,7 @@ import {
Skeleton,
Tooltip,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import type { DeployReadiness } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deploy-readiness'
import { Preview, PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
@@ -59,7 +60,6 @@ export function GeneralDeploy({
}: GeneralDeployProps) {
const [selectedVersion, setSelectedVersion] = useState<number | null>(null)
const [showActiveDespiteSelection, setShowActiveDespiteSelection] = useState(false)
// Derived — no useEffect needed
const previewMode: PreviewMode =
selectedVersion !== null && !showActiveDespiteSelection ? 'selected' : 'active'
const [showLoadDialog, setShowLoadDialog] = useState(false)
@@ -82,28 +82,22 @@ export function GeneralDeploy({
const revertMutation = useRevertToVersion()
const handleSelectVersion = useCallback((version: number | null) => {
const handleSelectVersion = (version: number | null) => {
setSelectedVersion(version)
setShowActiveDespiteSelection(false)
}, [])
}
const handleLoadDeployment = useCallback(
(version: number) => {
if (!workflowId) return
setVersionToLoad({ workflowId, version })
setShowLoadDialog(true)
},
[workflowId]
)
const handleLoadDeployment = (version: number) => {
if (!workflowId) return
setVersionToLoad({ workflowId, version })
setShowLoadDialog(true)
}
const handlePromoteToLive = useCallback(
(version: number) => {
if (!workflowId) return
setVersionToPromote({ workflowId, version })
setShowPromoteDialog(true)
},
[workflowId]
)
const handlePromoteToLive = (version: number) => {
if (!workflowId) return
setVersionToPromote({ workflowId, version })
setShowPromoteDialog(true)
}
const confirmLoadDeployment = async () => {
if (!versionToLoad) return
@@ -168,12 +162,8 @@ export function GeneralDeploy({
}
}
const workflowToShow = useMemo(() => {
if (previewMode === 'selected' && selectedVersionState) {
return selectedVersionState
}
return deployedState
}, [previewMode, selectedVersionState, deployedState])
const workflowToShow =
previewMode === 'selected' && selectedVersionState ? selectedVersionState : deployedState
const showToggle = selectedVersion !== null && deployedState
@@ -211,10 +201,7 @@ export function GeneralDeploy({
? selectedVersionInfo.name || `v${selectedVersion}`
: 'Live Workflow'}
</Label>
<div
className='absolute top-[-5px] right-0'
style={{ visibility: showToggle ? 'visible' : 'hidden' }}
>
<div className={cn('absolute top-[-5px] right-0', !showToggle && 'invisible')}>
<ButtonGroup
value={previewMode}
onValueChange={(val) =>
@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import {
@@ -51,6 +51,16 @@ function haveSameServerSelection(a: string[], b: string[]): boolean {
return a.every((id) => bSet.has(id))
}
function haveSameParameterDescriptions(
a: Record<string, string>,
b: Record<string, string>
): boolean {
const aKeys = Object.keys(a)
const bKeys = Object.keys(b)
if (aKeys.length !== bKeys.length) return false
return aKeys.every((key) => a[key] === b[key])
}
/**
* Generate JSON Schema from input format with optional descriptions
*/
@@ -187,14 +197,19 @@ export function McpDeploy({
return ids
}, [servers, serverToolsMap])
const [draftSelectedServerIds, setDraftSelectedServerIds] = useState<string[] | null>(null)
const hasLoadedInitialData = useRef(false)
const [savedValues, setSavedValues] = useState<{
toolName: string
toolDescription: string
parameterDescriptions: Record<string, string>
} | null>(null)
useEffect(() => {
if (savedValues) return
for (const server of servers) {
const toolInfo = serverToolsMap[server.id]
if (toolInfo?.tool) {
setToolName(toolInfo.tool.toolName)
const initialToolName = toolInfo.tool.toolName
const loadedDescription = toolInfo.tool.toolDescription || ''
const normalizedLoadedDesc = loadedDescription.toLowerCase().trim()
@@ -203,48 +218,37 @@ export function McpDeploy({
loadedDescription === workflowName ||
normalizedLoadedDesc === 'new workflow' ||
normalizedLoadedDesc === 'your first workflow - start building here!'
setToolDescription(isDefaultDescription ? '' : loadedDescription)
const initialToolDescription = isDefaultDescription ? '' : loadedDescription
const schema = toolInfo.tool.parameterSchema as Record<string, unknown> | undefined
const properties = schema?.properties as
| Record<string, { description?: string }>
| undefined
const initialParameterDescriptions: Record<string, string> = {}
if (properties) {
const descriptions: Record<string, string> = {}
for (const [name, prop] of Object.entries(properties)) {
if (
prop.description &&
prop.description !== name &&
prop.description !== 'Array of file objects'
) {
descriptions[name] = prop.description
initialParameterDescriptions[name] = prop.description
}
}
if (Object.keys(descriptions).length > 0) {
setParameterDescriptions(descriptions)
}
}
hasLoadedInitialData.current = true
setToolName(initialToolName)
setToolDescription(initialToolDescription)
setParameterDescriptions(initialParameterDescriptions)
setSavedValues({
toolName: initialToolName,
toolDescription: initialToolDescription,
parameterDescriptions: initialParameterDescriptions,
})
break
}
}
}, [servers, serverToolsMap, workflowName])
const [savedValues, setSavedValues] = useState<{
toolName: string
toolDescription: string
parameterDescriptions: Record<string, string>
} | null>(null)
useEffect(() => {
if (hasLoadedInitialData.current && !savedValues) {
setSavedValues({
toolName,
toolDescription,
parameterDescriptions: { ...parameterDescriptions },
})
}
}, [toolName, toolDescription, parameterDescriptions, savedValues])
}, [servers, serverToolsMap, workflowName, savedValues])
const selectedServerIdsForForm = draftSelectedServerIds ?? selectedServerIds
@@ -252,9 +256,7 @@ export function McpDeploy({
if (!savedValues) return false
if (toolName !== savedValues.toolName) return true
if (toolDescription !== savedValues.toolDescription) return true
if (
JSON.stringify(parameterDescriptions) !== JSON.stringify(savedValues.parameterDescriptions)
) {
if (!haveSameParameterDescriptions(parameterDescriptions, savedValues.parameterDescriptions)) {
return true
}
return false
@@ -271,10 +273,7 @@ export function McpDeploy({
onCanSaveChange?.(hasChanges && !!toolName.trim())
}, [hasChanges, toolName, onCanSaveChange])
/**
* Save tool configuration to all deployed servers
*/
const handleSave = useCallback(async () => {
const handleSave = async () => {
if (!toolName.trim()) return
const currentIds = new Set(selectedServerIds)
@@ -390,25 +389,7 @@ export function McpDeploy({
logger.error('Failed to save tool configuration:', error)
onSubmittingChange?.(false)
}
}, [
toolName,
toolDescription,
parameterDescriptions,
parameterSchema,
selectedServerIds,
selectedServerIdsForForm,
hasToolConfigurationChanges,
serverToolsMap,
workspaceId,
workflowId,
servers,
addToolMutation,
deleteToolMutation,
updateToolMutation,
onAddedToServer,
onSubmittingChange,
onCanSaveChange,
])
}
const serverOptions: ComboboxOption[] = useMemo(() => {
return servers.map((server) => ({
@@ -417,9 +398,9 @@ export function McpDeploy({
}))
}, [servers])
const handleServerSelectionChange = useCallback((newSelectedIds: string[]) => {
const handleServerSelectionChange = (newSelectedIds: string[]) => {
setDraftSelectedServerIds(newSelectedIds)
}, [])
}
const selectedServersLabel = useMemo(() => {
const count = selectedServerIdsForForm.length
@@ -491,7 +472,6 @@ export function McpDeploy({
handleSave()
}}
>
{/* Hidden submit button for parent modal to trigger */}
<button type='submit' hidden />
{servers.map((server) => (
@@ -1,483 +0,0 @@
'use client'
import { forwardRef, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
Button,
Combobox,
Input,
Label,
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
Skeleton,
TagInput,
Textarea,
} from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { captureAndUploadOGImage, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from '@/lib/og'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { useCreatorProfiles } from '@/hooks/queries/creator-profile'
import {
useCreateTemplate,
useDeleteTemplate,
useTemplateByWorkflow,
useUpdateTemplate,
} from '@/hooks/queries/templates'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('TemplateDeploy')
interface TemplateFormData {
name: string
tagline: string
about: string
creatorId: string
tags: string[]
}
const initialFormData: TemplateFormData = {
name: '',
tagline: '',
about: '',
creatorId: '',
tags: [],
}
interface TemplateDeployProps {
workflowId: string
onDeploymentComplete?: () => void
onValidationChange?: (isValid: boolean) => void
onSubmittingChange?: (isSubmitting: boolean) => void
}
export function TemplateDeploy({
workflowId,
onDeploymentComplete,
onValidationChange,
onSubmittingChange,
}: TemplateDeployProps) {
const { data: session } = useSession()
const { navigateToSettings } = useSettingsNavigation()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isCapturing, setIsCapturing] = useState(false)
const previewContainerRef = useRef<HTMLDivElement>(null)
const ogCaptureRef = useRef<HTMLDivElement>(null)
const [formData, setFormData] = useState<TemplateFormData>(initialFormData)
const { data: existingTemplate, isLoading: isLoadingTemplate } = useTemplateByWorkflow(workflowId)
const { data: creatorProfiles = [], isLoading: loadingCreators } = useCreatorProfiles()
const createMutation = useCreateTemplate()
const updateMutation = useUpdateTemplate()
const deleteMutation = useDeleteTemplate()
const isSubmitting = createMutation.isPending || updateMutation.isPending
const isFormValid =
formData.name.trim().length > 0 &&
formData.name.length <= 100 &&
formData.tagline.length <= 200 &&
formData.creatorId.length > 0
const updateField = <K extends keyof TemplateFormData>(field: K, value: TemplateFormData[K]) => {
setFormData((prev) => ({ ...prev, [field]: value }))
}
useEffect(() => {
onValidationChange?.(isFormValid)
}, [isFormValid, onValidationChange])
useEffect(() => {
onSubmittingChange?.(isSubmitting)
}, [isSubmitting, onSubmittingChange])
useEffect(() => {
if (creatorProfiles.length === 1 && !formData.creatorId) {
updateField('creatorId', creatorProfiles[0].id)
logger.info('Auto-selected single creator profile:', creatorProfiles[0].name)
}
}, [creatorProfiles, formData.creatorId])
useEffect(() => {
const handleCreatorProfileSaved = () => {
logger.info('Creator profile saved, returning to deploy modal...')
setTimeout(() => {
window.dispatchEvent(new CustomEvent('open-deploy-modal', { detail: { tab: 'template' } }))
}, 100)
}
window.addEventListener('creator-profile-saved', handleCreatorProfileSaved)
return () => {
window.removeEventListener('creator-profile-saved', handleCreatorProfileSaved)
}
}, [])
useEffect(() => {
if (existingTemplate) {
setFormData({
name: existingTemplate.name,
tagline: existingTemplate.details?.tagline || '',
about: existingTemplate.details?.about || '',
creatorId: existingTemplate.creatorId || '',
tags: existingTemplate.tags || [],
})
}
}, [existingTemplate])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!session?.user || !isFormValid) {
logger.error('User not authenticated or form invalid')
return
}
try {
const templateData = {
name: formData.name.trim(),
details: {
tagline: formData.tagline.trim(),
about: formData.about.trim(),
},
creatorId: formData.creatorId,
tags: formData.tags,
}
let templateId: string
if (existingTemplate) {
await updateMutation.mutateAsync({
id: existingTemplate.id,
data: {
...templateData,
updateState: true,
},
})
templateId = existingTemplate.id
} else {
const result = await createMutation.mutateAsync({ ...templateData, workflowId })
templateId = result.id
}
logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully`)
setIsCapturing(true)
requestAnimationFrame(() => {
requestAnimationFrame(async () => {
try {
if (ogCaptureRef.current) {
const ogUrl = await captureAndUploadOGImage(ogCaptureRef.current, templateId)
if (ogUrl) {
logger.info(`OG image uploaded for template ${templateId}: ${ogUrl}`)
}
}
} catch (ogError) {
logger.warn('Failed to capture/upload OG image:', ogError)
} finally {
setIsCapturing(false)
}
})
})
onDeploymentComplete?.()
} catch (error) {
logger.error('Failed to save template:', error)
}
}
const handleDelete = async () => {
if (!existingTemplate) return
try {
await deleteMutation.mutateAsync(existingTemplate.id)
setShowDeleteDialog(false)
setFormData(initialFormData)
} catch (error) {
logger.error('Error deleting template:', error)
}
}
if (isLoadingTemplate) {
return (
<div className='space-y-3'>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[40px]' />
<Skeleton className='h-[34px] w-full rounded-sm' />
</div>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[50px]' />
<Skeleton className='h-[34px] w-full rounded-sm' />
</div>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[76px]' />
<Skeleton className='h-[160px] w-full rounded-sm' />
</div>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[50px]' />
<Skeleton className='h-[34px] w-full rounded-sm' />
</div>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[32px]' />
<Skeleton className='h-[34px] w-full rounded-sm' />
</div>
</div>
)
}
return (
<div className='space-y-4'>
<form id='template-deploy-form' onSubmit={handleSubmit} className='space-y-3'>
{existingTemplate?.state && (
<div>
<Label className='mb-[6.5px] block pl-0.5 font-medium text-[var(--text-primary)] text-small'>
Live Template
</Label>
<div
ref={previewContainerRef}
className='[&_*]:!cursor-default relative h-[260px] w-full cursor-default overflow-hidden rounded-sm border border-[var(--border)]'
onWheelCapture={(e) => {
if (e.ctrlKey || e.metaKey) return
e.stopPropagation()
}}
>
<TemplatePreviewContent existingTemplate={existingTemplate} />
</div>
</div>
)}
<div>
<Label className='mb-[6.5px] block pl-0.5 font-medium text-[var(--text-primary)] text-small'>
Name <span className='text-[var(--text-error)]'>*</span>
</Label>
<Input
placeholder='Deep Research Agent'
value={formData.name}
onChange={(e) => updateField('name', e.target.value)}
disabled={isSubmitting}
/>
</div>
<div>
<Label className='mb-[6.5px] block pl-0.5 font-medium text-[var(--text-primary)] text-small'>
Tagline
</Label>
<Input
placeholder='A deep research agent that can find information on any topic'
value={formData.tagline}
onChange={(e) => updateField('tagline', e.target.value)}
disabled={isSubmitting}
className={cn(formData.tagline.length > 200 && 'border-[var(--text-error)]')}
/>
</div>
<div>
<Label className='mb-[6.5px] block pl-0.5 font-medium text-[var(--text-primary)] text-small'>
Description
</Label>
<Textarea
placeholder='Optional description that supports Markdown'
className='min-h-[160px] resize-none'
value={formData.about}
onChange={(e) => updateField('about', e.target.value)}
disabled={isSubmitting}
/>
</div>
<div>
<Label className='mb-[6.5px] block pl-0.5 font-medium text-[var(--text-primary)] text-small'>
Creator <span className='text-[var(--text-error)]'>*</span>
</Label>
{creatorProfiles.length === 0 && !loadingCreators ? (
<div className='space-y-2'>
<p className='text-[var(--text-tertiary)] text-caption'>
A creator profile is required to publish templates.
</p>
<Button
type='button'
variant='tertiary'
onClick={() => {
navigateToSettings({ section: 'template-profile' })
}}
className='gap-2'
>
<span>Create Template Profile</span>
</Button>
</div>
) : (
<Combobox
options={creatorProfiles.map((profile) => ({
label: profile.name,
value: profile.id,
}))}
value={formData.creatorId}
selectedValue={formData.creatorId}
onChange={(value) => updateField('creatorId', value)}
placeholder={loadingCreators ? 'Loading...' : 'Select creator profile'}
editable={false}
filterOptions={false}
disabled={loadingCreators || isSubmitting}
/>
)}
</div>
<div>
<Label className='mb-[6.5px] block pl-0.5 font-medium text-[var(--text-primary)] text-small'>
Tags
</Label>
<TagInput
items={formData.tags.map((tag) => ({ value: tag, isValid: true }))}
onAdd={(value) => {
if (!formData.tags.includes(value) && formData.tags.length < 10) {
updateField('tags', [...formData.tags, value])
return true
}
return false
}}
onRemove={(_value, index) => {
updateField(
'tags',
formData.tags.filter((_, i) => i !== index)
)
}}
placeholder='Dev, Agents, Research, etc.'
placeholderWithTags='Add another'
tagVariant='secondary'
triggerKeys={['Enter', ',']}
disabled={isSubmitting}
/>
</div>
<button
type='button'
data-template-delete-trigger
onClick={() => setShowDeleteDialog(true)}
style={{ display: 'none' }}
/>
</form>
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent size='sm'>
<ModalHeader>Delete Template</ModalHeader>
<ModalBody>
<ModalDescription className='text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>
{existingTemplate?.name || formData.name || 'this template'}
</span>
? This action cannot be undone.
</ModalDescription>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowDeleteDialog(false)}>
Cancel
</Button>
<Button
variant='destructive'
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Hidden container for OG image capture */}
{isCapturing && <OGCaptureContainer ref={ogCaptureRef} />}
</div>
)
}
/**
* Hidden container for OG image capture.
* Lazy-rendered only when capturing - gets workflow state from store on mount.
*/
const OGCaptureContainer = forwardRef<HTMLDivElement>((_, ref) => {
const blocks = useWorkflowStore((state) => state.blocks)
const edges = useWorkflowStore((state) => state.edges)
const loops = useWorkflowStore((state) => state.loops)
const parallels = useWorkflowStore((state) => state.parallels)
if (!blocks || Object.keys(blocks).length === 0) {
return null
}
const workflowState: WorkflowState = {
blocks,
edges: edges ?? [],
loops: loops ?? {},
parallels: parallels ?? {},
lastSaved: Date.now(),
}
return (
<div
ref={ref}
style={{
position: 'absolute',
left: '-9999px',
top: '-9999px',
width: OG_IMAGE_WIDTH,
height: OG_IMAGE_HEIGHT,
backgroundColor: '#0c0c0c',
overflow: 'hidden',
}}
aria-hidden='true'
>
<PreviewWorkflow
workflowState={workflowState}
height='100%'
width='100%'
isPannable={false}
defaultZoom={0.8}
fitPadding={0.2}
lightweight
/>
</div>
)
})
OGCaptureContainer.displayName = 'OGCaptureContainer'
interface TemplatePreviewContentProps {
existingTemplate:
| {
id: string
state?: Partial<WorkflowState>
}
| null
| undefined
}
function TemplatePreviewContent({ existingTemplate }: TemplatePreviewContentProps) {
if (!existingTemplate?.state || !existingTemplate.state.blocks) {
return null
}
const workflowState: WorkflowState = {
blocks: existingTemplate.state.blocks,
edges: existingTemplate.state.edges ?? [],
loops: existingTemplate.state.loops ?? {},
parallels: existingTemplate.state.parallels ?? {},
lastSaved: existingTemplate.state.lastSaved ?? Date.now(),
}
return (
<PreviewWorkflow
key={`template-preview-${existingTemplate.id}`}
workflowState={workflowState}
height='100%'
width='100%'
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.6}
/>
)
}
@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { useQueryClient } from '@tanstack/react-query'
@@ -43,7 +43,6 @@ import {
useDeployWorkflow,
useUndeployWorkflow,
} from '@/hooks/queries/deployments'
// import { useTemplateByWorkflow } from '@/hooks/queries/templates'
import { useWorkflowMcpServers } from '@/hooks/queries/workflow-mcp-servers'
import { useWorkflowMap } from '@/hooks/queries/workflows'
import { useWorkspaceSettings } from '@/hooks/queries/workspace'
@@ -60,8 +59,6 @@ import { ApiInfoModal } from './components/general/components/api-info-modal'
import { GeneralDeploy } from './components/general/general'
import { McpDeploy } from './components/mcp/mcp'
// import { TemplateDeploy } from './components/template/template'
const logger = createLogger('DeployModal')
interface DeployModalProps {
@@ -86,7 +83,13 @@ interface WorkflowDeploymentInfoUI {
isPublicApi: boolean
}
type TabView = 'general' | 'api' | 'chat' | /* 'template' | */ 'mcp' | 'form' | 'a2a'
type TabView = 'general' | 'api' | 'chat' | 'mcp' | 'a2a'
const DEPLOY_MODAL_TABS = new Set<TabView>(['general', 'api', 'chat', 'mcp', 'a2a'])
function isDeployModalTab(value: unknown): value is TabView {
return typeof value === 'string' && DEPLOY_MODAL_TABS.has(value as TabView)
}
export function DeployModal({
open,
@@ -117,8 +120,6 @@ export function DeployModal({
const [selectedStreamingOutputs, setSelectedStreamingOutputs] = useState<string[]>([])
const [undeployTargetWorkflowId, setUndeployTargetWorkflowId] = useState<string | null>(null)
// const [templateFormValid, setTemplateFormValid] = useState(false)
// const [templateSubmitting, setTemplateSubmitting] = useState(false)
const [mcpToolSubmitting, setMcpToolSubmitting] = useState(false)
const [mcpToolCanSave, setMcpToolCanSave] = useState(false)
const [a2aSubmitting, setA2aSubmitting] = useState(false)
@@ -176,29 +177,17 @@ export function DeployModal({
const hasA2aAgent = !!existingA2aAgent
const isA2aPublished = existingA2aAgent?.isPublished ?? false
// const { data: existingTemplate } = useTemplateByWorkflow(workflowId || '', {
// enabled: !!workflowId,
// })
// const hasExistingTemplate = !!existingTemplate
// const templateStatus = existingTemplate
// ? {
// status: existingTemplate.status as 'pending' | 'approved' | 'rejected' | null,
// views: existingTemplate.views,
// stars: existingTemplate.stars,
// }
// : null
const deployMutation = useDeployWorkflow()
const undeployMutation = useUndeployWorkflow()
const activateVersionMutation = useActivateDeploymentVersion()
const versions = versionsData?.versions ?? []
const isWorkflowStillActive = useCallback((targetWorkflowId: string) => {
const isWorkflowStillActive = (targetWorkflowId: string) => {
return useWorkflowRegistry.getState().activeWorkflowId === targetWorkflowId
}, [])
}
const syncDraftAfterDeploy = useCallback(async (): Promise<string | null> => {
const syncDraftAfterDeploy = async (): Promise<string | null> => {
if (!workflowId) return null
try {
@@ -215,7 +204,7 @@ export function DeployModal({
})
return 'Deployment succeeded, but local sync failed. Refresh if the status looks stale.'
}
}, [workflowId, isWorkflowStillActive])
}
useEffect(() => {
deployActionIdRef.current += 1
@@ -223,29 +212,21 @@ export function DeployModal({
setUndeployTargetWorkflowId(null)
}, [workflowId])
const getApiKeyLabel = useCallback(
(value?: string | null) => {
if (value && value.trim().length > 0) {
return value
}
return workflowWorkspaceId ? 'Workspace API keys' : 'Personal API keys'
},
[workflowWorkspaceId]
)
const getApiKeyLabel = (value?: string | null) => {
if (value && value.trim().length > 0) {
return value
}
return workflowWorkspaceId ? 'Workspace API keys' : 'Personal API keys'
}
const getApiHeaderPlaceholder = useCallback(
() => (workflowWorkspaceId ? 'YOUR_WORKSPACE_API_KEY' : 'YOUR_PERSONAL_API_KEY'),
[workflowWorkspaceId]
)
const getApiHeaderPlaceholder = () =>
workflowWorkspaceId ? 'YOUR_WORKSPACE_API_KEY' : 'YOUR_PERSONAL_API_KEY'
const getInputFormatExample = useCallback(
(includeStreaming = false) => {
return getInputFormatExampleUtil(includeStreaming, selectedStreamingOutputs)
},
[selectedStreamingOutputs]
)
const getInputFormatExample = (includeStreaming = false) => {
return getInputFormatExampleUtil(includeStreaming, selectedStreamingOutputs)
}
const deploymentInfo: WorkflowDeploymentInfoUI | null = useMemo(() => {
const deploymentInfo: WorkflowDeploymentInfoUI | null = (() => {
if (!deploymentInfoData?.isDeployed || !workflowId) {
return null
}
@@ -263,15 +244,7 @@ export function DeployModal({
needsRedeployment: deploymentInfoData.needsRedeployment,
isPublicApi: isPublicApiDisabled ? false : (deploymentInfoData.isPublicApi ?? false),
}
}, [
deploymentInfoData,
isPublicApiDisabled,
workflowId,
selectedStreamingOutputs,
getInputFormatExample,
getApiHeaderPlaceholder,
getApiKeyLabel,
])
})()
const selectedStreamingOutputsRef = useRef(selectedStreamingOutputs)
selectedStreamingOutputsRef.current = selectedStreamingOutputs
@@ -316,9 +289,9 @@ export function DeployModal({
useEffect(() => {
const handleOpenDeployModal = (event: Event) => {
const customEvent = event as CustomEvent<{ tab?: TabView }>
const customEvent = event as CustomEvent<{ tab?: unknown }>
onOpenChange(true)
if (customEvent.detail?.tab) {
if (isDeployModalTab(customEvent.detail?.tab)) {
setActiveTab(customEvent.detail.tab)
}
}
@@ -330,7 +303,7 @@ export function DeployModal({
}
}, [onOpenChange])
const onDeploy = useCallback(async () => {
const onDeploy = async () => {
if (!workflowId) return
if (!tryAcquireDeployAction(workflowId)) return
@@ -370,36 +343,33 @@ export function DeployModal({
setIsFinalizingDeploy(false)
}
}
}, [workflowId, deployMutation, deployReadiness, syncDraftAfterDeploy, isWorkflowStillActive])
}
const handlePromoteToLive = useCallback(
async (version: number) => {
if (!workflowId) return
if (activateVersionInFlightRef.current) return
const handlePromoteToLive = async (version: number) => {
if (!workflowId) return
if (activateVersionInFlightRef.current) return
activateVersionInFlightRef.current = true
setIsActivatingVersion(true)
setDeployWarnings([])
activateVersionInFlightRef.current = true
setIsActivatingVersion(true)
setDeployWarnings([])
try {
const result = await activateVersionMutation.mutateAsync({ workflowId, version })
if (!isWorkflowStillActive(workflowId)) return
if (result.warnings && result.warnings.length > 0) {
setDeployWarnings(result.warnings)
}
} catch (error) {
if (!isWorkflowStillActive(workflowId)) return
logger.error('Error promoting version:', { error })
throw error
} finally {
activateVersionInFlightRef.current = false
setIsActivatingVersion(false)
try {
const result = await activateVersionMutation.mutateAsync({ workflowId, version })
if (!isWorkflowStillActive(workflowId)) return
if (result.warnings && result.warnings.length > 0) {
setDeployWarnings(result.warnings)
}
},
[workflowId, activateVersionMutation, isWorkflowStillActive]
)
} catch (error) {
if (!isWorkflowStillActive(workflowId)) return
logger.error('Error promoting version:', { error })
throw error
} finally {
activateVersionInFlightRef.current = false
setIsActivatingVersion(false)
}
}
const handleUndeploy = useCallback(async () => {
const handleUndeploy = async () => {
if (!undeployTargetWorkflowId) return
const targetWorkflowId = undeployTargetWorkflowId
if (workflowId !== targetWorkflowId || !isWorkflowStillActive(targetWorkflowId)) {
@@ -422,9 +392,9 @@ export function DeployModal({
if (!isWorkflowStillActive(targetWorkflowId)) return
logger.error('Error undeploying workflow:', { error })
}
}, [workflowId, undeployTargetWorkflowId, undeployMutation, onOpenChange, isWorkflowStillActive])
}
const handleRedeploy = useCallback(async () => {
const handleRedeploy = async () => {
if (!workflowId) return
if (!tryAcquireDeployAction(workflowId)) return
@@ -478,9 +448,9 @@ export function DeployModal({
setIsFinalizingDeploy(false)
}
}
}, [workflowId, deployMutation, deployReadiness, syncDraftAfterDeploy, isWorkflowStillActive])
}
const handleCloseModal = useCallback(() => {
const handleCloseModal = () => {
deployActionIdRef.current += 1
setIsFinalizingDeploy(false)
if (workflowId) releaseDeployAction(workflowId)
@@ -488,9 +458,9 @@ export function DeployModal({
setDeployError(null)
setDeployWarnings([])
onOpenChange(false)
}, [workflowId, onOpenChange])
}
const handleChatDeployed = useCallback(async () => {
const handleChatDeployed = async () => {
if (!workflowId) return
invalidateDeploymentQueries(queryClient, workflowId)
@@ -500,18 +470,18 @@ export function DeployModal({
}
setChatSuccess(true)
chatSuccessTimeoutRef.current = setTimeout(() => setChatSuccess(false), 2000)
}, [workflowId, queryClient])
}
const handleRefetchChat = useCallback(async () => {
const handleRefetchChat = async () => {
await refetchChatInfo()
}, [refetchChatInfo])
}
const handleChatFormSubmit = useCallback(() => {
const handleChatFormSubmit = () => {
const form = document.getElementById('chat-deploy-form') as HTMLFormElement
form?.requestSubmit()
}, [])
}
const handleChatDelete = useCallback(() => {
const handleChatDelete = () => {
const form = document.getElementById('chat-deploy-form') as HTMLFormElement
if (form) {
const deleteButton = form.querySelector('[data-delete-trigger]') as HTMLButtonElement
@@ -519,60 +489,49 @@ export function DeployModal({
deleteButton.click()
}
}
}, [])
}
// const handleTemplateFormSubmit = useCallback(() => {
// const form = document.getElementById('template-deploy-form') as HTMLFormElement
// form?.requestSubmit()
// }, [])
const handleMcpToolFormSubmit = useCallback(() => {
const handleMcpToolFormSubmit = () => {
const form = document.getElementById('mcp-deploy-form') as HTMLFormElement
form?.requestSubmit()
}, [])
}
const handleA2aPublish = useCallback(() => {
const handleA2aPublish = () => {
const form = document.getElementById('a2a-deploy-form')
const publishTrigger = form?.querySelector('[data-a2a-publish-trigger]') as HTMLButtonElement
publishTrigger?.click()
}, [])
}
const handleA2aUnpublish = useCallback(() => {
const handleA2aUnpublish = () => {
const form = document.getElementById('a2a-deploy-form')
const unpublishTrigger = form?.querySelector(
'[data-a2a-unpublish-trigger]'
) as HTMLButtonElement
unpublishTrigger?.click()
}, [])
}
const handleA2aPublishNew = useCallback(() => {
const handleA2aPublishNew = () => {
const form = document.getElementById('a2a-deploy-form')
const publishNewTrigger = form?.querySelector(
'[data-a2a-publish-new-trigger]'
) as HTMLButtonElement
publishNewTrigger?.click()
}, [])
}
const handleA2aUpdateRepublish = useCallback(() => {
const handleA2aUpdateRepublish = () => {
const form = document.getElementById('a2a-deploy-form')
const updateRepublishTrigger = form?.querySelector(
'[data-a2a-update-republish-trigger]'
) as HTMLButtonElement
updateRepublishTrigger?.click()
}, [])
}
const handleA2aDelete = useCallback(() => {
const handleA2aDelete = () => {
const form = document.getElementById('a2a-deploy-form')
const deleteTrigger = form?.querySelector('[data-a2a-delete-trigger]') as HTMLButtonElement
deleteTrigger?.click()
setShowA2aDeleteConfirm(false)
}, [])
// const handleTemplateDelete = useCallback(() => {
// const form = document.getElementById('template-deploy-form')
// const deleteTrigger = form?.querySelector('[data-template-delete-trigger]') as HTMLButtonElement
// deleteTrigger?.click()
// }, [])
}
const isSubmitting = deployMutation.isPending || isFinalizingDeploy
const isUndeploying = undeployMutation.isPending
@@ -672,32 +631,6 @@ export function DeployModal({
/>
</ModalTabsContent>
{/* <ModalTabsContent value='template'>
{workflowId && (
<TemplateDeploy
workflowId={workflowId}
onDeploymentComplete={handleCloseModal}
onValidationChange={setTemplateFormValid}
onSubmittingChange={setTemplateSubmitting}
/>
)}
</ModalTabsContent> */}
{/* <ModalTabsContent value='form'>
{workflowId && (
<FormDeploy
workflowId={workflowId}
onDeploymentComplete={handleCloseModal}
onValidationChange={setIsFormValid}
onSubmittingChange={setFormSubmitting}
onExistingFormChange={setFormExists}
formSubmitting={formSubmitting}
setFormSubmitting={setFormSubmitting}
onDeployed={handleFormDeployed}
/>
)}
</ModalTabsContent> */}
<ModalTabsContent value='mcp' className='h-full'>
{workflowId && (
<McpDeploy
@@ -818,83 +751,8 @@ export function DeployModal({
</div>
</ModalFooter>
)}
{/* {activeTab === 'template' && (
<ModalFooter className='items-center justify-between'>
{hasExistingTemplate && templateStatus ? (
<TemplateStatusBadge
status={templateStatus.status}
views={templateStatus.views}
stars={templateStatus.stars}
/>
) : (
<div />
)}
<div className='flex items-center gap-2'>
{hasExistingTemplate && (
<Button
type='button'
variant='destructive'
onClick={handleTemplateDelete}
disabled={templateSubmitting}
>
Delete
</Button>
)}
<Button
type='button'
variant='tertiary'
onClick={handleTemplateFormSubmit}
disabled={templateSubmitting || !templateFormValid}
>
{templateSubmitting
? hasExistingTemplate
? 'Updating...'
: 'Publishing...'
: hasExistingTemplate
? 'Update Template'
: 'Publish Template'}
</Button>
</div>
</ModalFooter>
)} */}
{/* {activeTab === 'form' && (
<ModalFooter className='items-center justify-between'>
<div />
<div className='flex items-center gap-2'>
{formExists && (
<Button
type='button'
variant='destructive'
onClick={handleFormDelete}
disabled={formSubmitting}
>
Delete
</Button>
)}
<Button
type='button'
variant='tertiary'
onClick={handleFormFormSubmit}
disabled={formSubmitting || !isFormValid}
>
{formSuccess
? formExists
? 'Updated'
: 'Launched'
: formSubmitting
? formExists
? 'Updating...'
: 'Launching...'
: formExists
? 'Update'
: 'Launch Form'}
</Button>
</div>
</ModalFooter>
)} */}
{activeTab === 'a2a' && (
<ModalFooter className='items-center justify-between'>
{/* Status badge on left */}
{hasA2aAgent ? (
isA2aPublished ? (
<Badge variant={a2aNeedsRepublish ? 'amber' : 'green'} size='lg' dot>
@@ -909,7 +767,6 @@ export function DeployModal({
<div />
)}
<div className='flex items-center gap-2'>
{/* No agent exists: Show "Publish Agent" button */}
{!hasA2aAgent && (
<Button
type='button'
@@ -921,7 +778,6 @@ export function DeployModal({
</Button>
)}
{/* Agent exists and published: Show Unpublish and Update */}
{hasA2aAgent && isA2aPublished && (
<>
<Button
@@ -943,7 +799,6 @@ export function DeployModal({
</>
)}
{/* Agent exists but unpublished: Show Delete and Publish */}
{hasA2aAgent && !isA2aPublished && (
<>
<Button
@@ -1067,29 +922,6 @@ function StatusBadge({ isWarning }: StatusBadgeProps) {
)
}
// interface TemplateStatusBadgeProps {
// status: 'pending' | 'approved' | 'rejected' | null
// views?: number
// stars?: number
// }
// function TemplateStatusBadge({ status, views, stars }: TemplateStatusBadgeProps) {
// const isPending = status === 'pending'
// const label = isPending ? 'Under review' : 'Live'
// const statsText =
// status === 'approved' && views !== undefined && views > 0
// ? `${views} views${stars !== undefined && stars > 0 ? ` • ${stars} stars` : ''}`
// : null
// return (
// <Badge variant={isPending ? 'amber' : 'green'} size='lg' dot>
// {label}
// {statsText && <span>• {statsText}</span>}
// </Badge>
// )
// }
interface GeneralFooterProps {
isDeployed?: boolean
needsRedeployment: boolean
@@ -1124,7 +956,7 @@ function GeneralFooter({
if (!isDeployed) {
return (
<ModalFooter className='items-center justify-between'>
<div className='max-w-[260px] text-muted-foreground text-xs'>{blockedMessage}</div>
<div className='max-w-[260px] text-[var(--text-muted)] text-xs'>{blockedMessage}</div>
<div className='flex items-center gap-2'>
<Button variant='tertiary' onClick={onDeploy} disabled={isDeployBlocked}>
{deployActionLoading && <Loader className='mr-1.5 size-3.5' animate />}
@@ -1140,7 +972,7 @@ function GeneralFooter({
<div className='flex min-w-0 flex-col gap-1'>
<StatusBadge isWarning={needsRedeployment} />
{blockedMessage && (
<div className='max-w-[300px] text-muted-foreground text-xs'>{blockedMessage}</div>
<div className='max-w-[300px] text-[var(--text-muted)] text-xs'>{blockedMessage}</div>
)}
</div>
<div className='flex items-center gap-2'>
+2 -2
View File
@@ -25,9 +25,9 @@ function buildColors() {
textTertiary: '#5c5c5c',
/** Muted text (footer) */
textMuted: '#737373',
/** Brand primary - purple */
/** Brand primary color */
brandPrimary:
isWhitelabeled && brand.theme?.primaryColor ? brand.theme.primaryColor : '#6f3dfa',
isWhitelabeled && brand.theme?.primaryColor ? brand.theme.primaryColor : '#33C482',
/** Brand accent - used for buttons and links */
brandTertiary: accentColor,
/** Border/divider color */
@@ -55,7 +55,7 @@ function DropZone({ onDrop, children, className }: DropZoneProps) {
>
{children}
{isDragging && (
<div className='pointer-events-none absolute inset-0 z-10 rounded-lg border-[1.5px] border-[var(--brand)] border-dashed bg-[color-mix(in_srgb,var(--brand)_8%,transparent)]' />
<div className='pointer-events-none absolute inset-0 z-10 rounded-lg border-[1.5px] border-[var(--brand-accent)] border-dashed bg-[color-mix(in_srgb,var(--brand-accent)_8%,transparent)]' />
)}
</div>
)
@@ -103,7 +103,7 @@ function ColorInput({ label, value, onChange, placeholder = '#000000' }: ColorIn
</div>
{!isValidHex && (
<p className='text-[12px] text-[var(--text-error)]'>
Must be a valid hex color (e.g. #701ffc)
Must be a valid hex color (e.g. #33c482)
</p>
)}
</div>
@@ -236,7 +236,7 @@ export function WhitelabelingSettings() {
for (const [fieldName, value] of colorFields) {
if (value && !HEX_COLOR_REGEX.test(value)) {
toast.error(`${fieldName} must be a valid hex color (e.g. #701ffc)`)
toast.error(`${fieldName} must be a valid hex color (e.g. #33c482)`)
return
}
}
@@ -462,25 +462,25 @@ export function WhitelabelingSettings() {
label='Primary color'
value={primaryColor}
onChange={setPrimaryColor}
placeholder='#701ffc'
placeholder='#33c482'
/>
<ColorInput
label='Primary hover color'
value={primaryHoverColor}
onChange={setPrimaryHoverColor}
placeholder='#802fff'
placeholder='#2dac72'
/>
<ColorInput
label='Accent color'
value={accentColor}
onChange={setAccentColor}
placeholder='#9d54ff'
placeholder='#33b4ff'
/>
<ColorInput
label='Accent hover color'
value={accentHoverColor}
onChange={setAccentHoverColor}
placeholder='#a66fff'
placeholder='#29a0e8'
/>
</div>
</section>
+2 -2
View File
@@ -44,11 +44,11 @@ export function generateThemeCSS(): string {
}
if (process.env.NEXT_PUBLIC_BRAND_ACCENT_COLOR) {
cssVars.push(`--brand-link: ${process.env.NEXT_PUBLIC_BRAND_ACCENT_COLOR};`)
cssVars.push(`--brand-accent: ${process.env.NEXT_PUBLIC_BRAND_ACCENT_COLOR};`)
}
if (process.env.NEXT_PUBLIC_BRAND_ACCENT_HOVER_COLOR) {
cssVars.push(`--brand-link-hover: ${process.env.NEXT_PUBLIC_BRAND_ACCENT_HOVER_COLOR};`)
cssVars.push(`--brand-accent-hover: ${process.env.NEXT_PUBLIC_BRAND_ACCENT_HOVER_COLOR};`)
}
if (process.env.NEXT_PUBLIC_BRAND_BACKGROUND_COLOR) {
+1 -1
View File
@@ -120,7 +120,7 @@ export function generateBrandedMetadata(override: Partial<Metadata> = {}): Metad
other: {
'apple-mobile-web-app-capable': 'yes',
'mobile-web-app-capable': 'yes',
'msapplication-TileColor': '#701FFC', // Default Sim brand primary color
'msapplication-TileColor': '#33C482',
'msapplication-config': 'none',
},
...override,
@@ -85,11 +85,11 @@ export function generateOrgThemeCSS(settings: OrganizationWhitelabelSettings): s
}
if (settings.accentColor) {
vars.push(`--brand-link: ${settings.accentColor};`)
vars.push(`--brand-accent: ${settings.accentColor};`)
}
if (settings.accentHoverColor) {
vars.push(`--brand-link-hover: ${settings.accentHoverColor};`)
vars.push(`--brand-accent-hover: ${settings.accentHoverColor};`)
}
return vars.length > 0 ? `:root { ${vars.join(' ')} }` : ''
+1 -1
View File
@@ -140,7 +140,7 @@ export const updateOrganizationWhitelabelBodySchema = z.object({
wordmarkUrl: z.string().min(1).nullable().optional(),
primaryColor: z
.string()
.regex(HEX_COLOR_REGEX, 'Primary color must be a valid hex color (e.g. #701ffc)')
.regex(HEX_COLOR_REGEX, 'Primary color must be a valid hex color (e.g. #33c482)')
.nullable()
.optional(),
primaryHoverColor: z
+4 -4
View File
@@ -14,10 +14,10 @@ export const defaultBrandConfig: BrandConfig = {
termsUrl: undefined,
privacyUrl: undefined,
theme: {
primaryColor: '#701ffc',
primaryHoverColor: '#802fff',
accentColor: '#9d54ff',
accentHoverColor: '#a66fff',
primaryColor: '#33c482',
primaryHoverColor: '#2dac72',
accentColor: '#33b4ff',
accentHoverColor: '#29a0e8',
backgroundColor: '#0c0c0c',
},
isWhitelabeled: false,
+1 -1
View File
@@ -1,4 +1,4 @@
/** Matches 3- or 6-digit hex colors, e.g. `#abc` or `#701ffc`. */
/** Matches 3- or 6-digit hex colors, e.g. `#abc` or `#33c482`. */
export const HEX_COLOR_REGEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i
export interface ThemeColors {
+1 -1
View File
@@ -449,7 +449,7 @@ export const env = createEnv({
NEXT_PUBLIC_PRIVACY_URL: z.string().url().optional(), // Custom privacy policy URL
// Theme Customization
NEXT_PUBLIC_BRAND_PRIMARY_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Primary brand color (hex format, e.g., "#701ffc")
NEXT_PUBLIC_BRAND_PRIMARY_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Primary brand color (hex format, e.g., "#33c482")
NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Primary brand hover state (hex format)
NEXT_PUBLIC_BRAND_ACCENT_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Accent brand color (hex format)
NEXT_PUBLIC_BRAND_ACCENT_HOVER_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Accent brand hover state (hex format)
-5
View File
@@ -88,10 +88,6 @@ export default {
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))',
},
gradient: {
primary: 'hsl(var(--gradient-primary))',
secondary: 'hsl(var(--gradient-secondary))',
},
gray: {
50: '#fafafa',
100: '#f5f5f5',
@@ -123,7 +119,6 @@ export default {
overlay: 'var(--shadow-overlay)',
kbd: 'var(--shadow-kbd)',
'kbd-sm': 'var(--shadow-kbd-sm)',
'brand-inset': 'var(--shadow-brand-inset)',
card: 'var(--shadow-card)',
},
dropShadow: {