Revert to checkpoint? This will restore your workflow to the state saved at this
checkpoint.{' '}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/index.ts
index a151aef4d..28de03e6c 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/index.ts
@@ -1,6 +1,6 @@
export * from './copilot-message/copilot-message'
-export * from './inline-tool-call/inline-tool-call'
export * from './plan-mode-section/plan-mode-section'
export * from './todo-list/todo-list'
+export * from './tool-call/tool-call'
export * from './user-input/user-input'
export * from './welcome/welcome'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/plan-mode-section/plan-mode-section.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/plan-mode-section/plan-mode-section.tsx
index 23f016823..ec59e6237 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/plan-mode-section/plan-mode-section.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/plan-mode-section/plan-mode-section.tsx
@@ -35,9 +35,9 @@ import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId
/**
* Shared border and background styles
*/
-const SURFACE_5 = 'bg-[var(--surface-5)] dark:bg-[var(--surface-5)]'
-const SURFACE_9 = 'bg-[var(--surface-9)] dark:bg-[var(--surface-9)]'
-const BORDER_STRONG = 'border-[var(--border-strong)] dark:border-[var(--border-strong)]'
+const SURFACE_5 = 'bg-[var(--surface-5)]'
+const SURFACE_9 = 'bg-[var(--surface-9)]'
+const BORDER_STRONG = 'border-[var(--border-strong)]'
export interface PlanModeSectionProps {
/**
@@ -184,8 +184,8 @@ const PlanModeSection: React.FC = ({
style={{ height: `${height}px` }}
>
{/* Header with build/edit/save/clear buttons */}
-
-
+
+
Workflow Plan
@@ -252,7 +252,7 @@ const PlanModeSection: React.FC
= ({
ref={textareaRef}
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
- className='h-full min-h-full w-full resize-none border-0 bg-transparent p-0 font-[470] font-season text-[13px] text-[var(--text-primary)] leading-[1.4rem] outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 dark:text-[var(--text-primary)]'
+ className='h-full min-h-full w-full resize-none border-0 bg-transparent p-0 font-[470] font-season text-[13px] text-[var(--text-primary)] leading-[1.4rem] outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0'
placeholder='Enter your workflow plan...'
/>
) : (
@@ -265,7 +265,7 @@ const PlanModeSection: React.FC = ({
className={cn(
'group flex h-[20px] w-full cursor-ns-resize items-center justify-center border-t',
BORDER_STRONG,
- 'transition-colors hover:bg-[var(--surface-9)] dark:hover:bg-[var(--surface-9)]',
+ 'transition-colors hover:bg-[var(--surface-9)]',
isResizing && SURFACE_9
)}
onMouseDown={handleResizeStart}
@@ -273,7 +273,7 @@ const PlanModeSection: React.FC = ({
aria-orientation='horizontal'
aria-label='Resize plan section'
>
-
+
)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/todo-list/todo-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/todo-list/todo-list.tsx
index 26a7e417b..8eafe6216 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/todo-list/todo-list.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/todo-list/todo-list.tsx
@@ -66,7 +66,7 @@ export const TodoList = memo(function TodoList({
return (
@@ -84,19 +84,17 @@ export const TodoList = memo(function TodoList({
)}
-
- Todo:
-
-
+ Todo:
+
{completedCount}/{totalCount}
{/* Progress bar */}
-
+
@@ -122,21 +120,20 @@ export const TodoList = memo(function TodoList({
key={todo.id}
className={cn(
'flex items-start gap-2 px-3 py-1.5 transition-colors hover:bg-[var(--surface-9)]/50 dark:hover:bg-[var(--surface-11)]/50',
- index !== todos.length - 1 &&
- 'border-[var(--surface-11)] border-b dark:border-[var(--surface-11)]'
+ index !== todos.length - 1 && 'border-[var(--surface-11)] border-b'
)}
>
{todo.executing ? (
-
+
) : (
{todo.completed ?
: null}
@@ -146,9 +143,7 @@ export const TodoList = memo(function TodoList({
{todo.content}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/inline-tool-call/inline-tool-call.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx
similarity index 96%
rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/inline-tool-call/inline-tool-call.tsx
rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx
index c43ae69ed..21a54d0bd 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/inline-tool-call/inline-tool-call.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx
@@ -12,7 +12,7 @@ import { getEnv } from '@/lib/core/config/env'
import { CLASS_TOOL_METADATA, useCopilotStore } from '@/stores/panel/copilot/store'
import type { CopilotToolCall } from '@/stores/panel/copilot/types'
-interface InlineToolCallProps {
+interface ToolCallProps {
toolCall?: CopilotToolCall
toolCallId?: string
onStateChange?: (state: any) => void
@@ -139,16 +139,11 @@ function ShimmerOverlayText({
// Special tools: use gradient for entire text
if (isSpecial) {
- const baseTextStyle = {
- backgroundImage: 'linear-gradient(90deg, #B99FFD 0%, #D1BFFF 100%)',
- WebkitBackgroundClip: 'text',
- backgroundClip: 'text',
- WebkitTextFillColor: 'transparent',
- }
-
return (
- {text}
+
+ {text}
+
{active ? (
{actionVerb ? (
<>
- {actionVerb}
- {remainder}
+ {actionVerb}
+ {remainder}
>
) : (
{text}
@@ -453,11 +448,7 @@ function RunSkipButtons({
)
}
-export function InlineToolCall({
- toolCall: toolCallProp,
- toolCallId,
- onStateChange,
-}: InlineToolCallProps) {
+export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: ToolCallProps) {
const [, forceUpdate] = useState({})
const liveToolCall = useCopilotStore((s) =>
toolCallId ? s.toolCallsById[toolCallId] : undefined
@@ -692,18 +683,18 @@ export function InlineToolCall({
return (
-
+
Name
-
+
Type
-
{ops.length === 0 ? (
-
+
No operations provided
) : (
@@ -723,7 +714,7 @@ export function InlineToolCall({
/>
-
+
{String(op.type || '')}
@@ -740,7 +731,7 @@ export function InlineToolCall({
className='w-full bg-transparent font-[470] font-mono text-amber-700 text-xs outline-none focus:text-amber-800 dark:text-amber-300 dark:focus:text-amber-200'
/>
) : (
-
+
—
)}
@@ -869,7 +860,7 @@ export function InlineToolCall({
text={displayName}
active={isLoadingState}
isSpecial={isSpecial}
- className='font-[470] font-season text-[#939393] text-sm dark:text-[#939393]'
+ className='font-[470] font-season text-[#3a3d41] text-sm dark:text-[#939393]'
/>
{renderPendingDetails()}
{showButtons && (
@@ -895,7 +886,7 @@ export function InlineToolCall({
text={displayName}
active={isLoadingState}
isSpecial={isSpecial}
- className='font-[470] font-season text-[#939393] text-sm dark:text-[#939393]'
+ className='font-[470] font-season text-[#3a3d41] text-sm dark:text-[#939393]'
/>
{isExpandableTool && expanded &&
{renderPendingDetails()}
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/mention-menu/mention-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/mention-menu/mention-menu.tsx
index 341c670db..74696f7e8 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/mention-menu/mention-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/mention-menu/mention-menu.tsx
@@ -552,11 +552,11 @@ export function MentionMenu({
active={index === submenuActiveIndex}
>
{log.workflowName}
-
·
+
·
{formatTimestamp(log.createdAt)}
-
·
+
·
{(log.trigger || 'manual').toLowerCase()}
@@ -583,9 +583,7 @@ export function MentionMenu({
{item.label}
{item.category === 'logs' && (
<>
-
- ·
-
+
·
{formatTimestamp(item.data.createdAt)}
@@ -758,15 +756,11 @@ export function MentionMenu({
mentionData.logsList.map((log) => (
insertLogMention(log)}>
{log.workflowName}
-
- ·
-
+ ·
{formatTimestamp(log.createdAt)}
-
- ·
-
+ ·
{(log.trigger || 'manual').toLowerCase()}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/welcome/welcome.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/welcome/welcome.tsx
index d270281c3..b4c9dc249 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/welcome/welcome.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/welcome/welcome.tsx
@@ -64,16 +64,14 @@ export function Welcome({ onQuestionClick, mode = 'ask' }: WelcomeProps) {
>
{title}
-
- {question}
-
+
{question}
))}
{/* Tips */}
-
+
Tip: Use @ to reference chats, workflows, knowledge,
blocks, or templates
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx
index 96b5404fc..07df6e8c4 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx
@@ -385,8 +385,8 @@ export const Copilot = forwardRef
(({ panelWidth }, ref
className='flex h-full flex-col overflow-hidden'
>
{/* Header */}
-
-
+
+
{currentChat?.title || 'New Chat'}
@@ -405,7 +405,7 @@ export const Copilot = forwardRef
(({ panelWidth }, ref
) : groupedChats.length === 0 ? (
-
+
No chats yet
) : (
@@ -460,7 +460,7 @@ export const Copilot = forwardRef
(({ panelWidth }, ref
{!isInitialized ? (
-
Loading chat history...
+
Loading copilot
) : (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api-endpoint.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api-endpoint.tsx
deleted file mode 100644
index c18800c12..000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api-endpoint.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-'use client'
-
-import { Label } from '@/components/emcn'
-import { CopyButton } from '@/components/ui/copy-button'
-
-interface ApiEndpointProps {
- endpoint: string
- showLabel?: boolean
-}
-
-export function ApiEndpoint({ endpoint, showLabel = true }: ApiEndpointProps) {
- return (
-
- {showLabel && (
-
- API Endpoint
-
- )}
-
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx
new file mode 100644
index 000000000..577e37ba7
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx
@@ -0,0 +1,664 @@
+'use client'
+
+import { useState } from 'react'
+import { Check, Clipboard } from 'lucide-react'
+import {
+ Badge,
+ Button,
+ Code,
+ Label,
+ Popover,
+ PopoverContent,
+ PopoverItem,
+ PopoverTrigger,
+ Tooltip,
+} from '@/components/emcn'
+import { Skeleton } from '@/components/ui'
+import { getEnv, isTruthy } from '@/lib/core/config/env'
+import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select'
+
+interface WorkflowDeploymentInfo {
+ isDeployed: boolean
+ deployedAt?: string
+ apiKey: string
+ endpoint: string
+ exampleCommand: string
+ needsRedeployment: boolean
+}
+
+interface ApiDeployProps {
+ workflowId: string | null
+ deploymentInfo: WorkflowDeploymentInfo | null
+ isLoading: boolean
+ needsRedeployment: boolean
+ apiDeployError: string | null
+ getInputFormatExample: (includeStreaming?: boolean) => string
+ selectedStreamingOutputs: string[]
+ onSelectedStreamingOutputsChange: (outputs: string[]) => void
+}
+
+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
+}
+
+const LANGUAGE_LABELS: Record = {
+ curl: 'cURL',
+ python: 'Python',
+ javascript: 'JavaScript',
+ typescript: 'TypeScript',
+}
+
+const LANGUAGE_SYNTAX: Record = {
+ curl: 'javascript',
+ python: 'python',
+ javascript: 'javascript',
+ typescript: 'javascript',
+}
+
+export function ApiDeploy({
+ workflowId,
+ deploymentInfo,
+ isLoading,
+ needsRedeployment,
+ apiDeployError,
+ getInputFormatExample,
+ selectedStreamingOutputs,
+ onSelectedStreamingOutputsChange,
+}: ApiDeployProps) {
+ const [asyncExampleType, setAsyncExampleType] = useState('execute')
+ const [language, setLanguage] = useState('curl')
+ const [copied, setCopied] = useState({
+ endpoint: false, // @remark: not used
+ sync: false,
+ stream: false,
+ async: false,
+ })
+
+ const isAsyncEnabled = isTruthy(getEnv('NEXT_PUBLIC_TRIGGER_DEV_ENABLED'))
+ const info = deploymentInfo ? { ...deploymentInfo, needsRedeployment } : null
+
+ const getBaseEndpoint = () => {
+ if (!info) return ''
+ return info.endpoint.replace(info.apiKey, '$SIM_API_KEY')
+ }
+
+ const getPayloadObject = (): Record => {
+ const inputExample = getInputFormatExample ? getInputFormatExample(false) : ''
+ const match = inputExample.match(/-d\s*'([\s\S]*)'/)
+ if (match) {
+ try {
+ return JSON.parse(match[1]) as Record
+ } catch {
+ return { input: 'your data here' }
+ }
+ }
+ return { input: 'your data here' }
+ }
+
+ const getStreamPayloadObject = (): Record => {
+ const payload: Record = { ...getPayloadObject(), stream: true }
+ if (selectedStreamingOutputs && selectedStreamingOutputs.length > 0) {
+ payload.selectedOutputs = selectedStreamingOutputs
+ }
+ return payload
+ }
+
+ const getSyncCommand = (): string => {
+ if (!info) return ''
+ const endpoint = getBaseEndpoint()
+ const payload = getPayloadObject()
+
+ switch (language) {
+ case 'curl':
+ return `curl -X POST \\
+ -H "X-API-Key: $SIM_API_KEY" \\
+ -H "Content-Type: application/json" \\
+ -d '${JSON.stringify(payload)}' \\
+ ${endpoint}`
+
+ case 'python':
+ return `import requests
+
+response = requests.post(
+ "${endpoint}",
+ headers={
+ "X-API-Key": SIM_API_KEY,
+ "Content-Type": "application/json"
+ },
+ json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')}
+)
+
+print(response.json())`
+
+ case 'javascript':
+ return `const response = await fetch("${endpoint}", {
+ method: "POST",
+ headers: {
+ "X-API-Key": SIM_API_KEY,
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify(${JSON.stringify(payload)})
+});
+
+const data = await response.json();
+console.log(data);`
+
+ case 'typescript':
+ return `const response = await fetch("${endpoint}", {
+ method: "POST",
+ headers: {
+ "X-API-Key": SIM_API_KEY,
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify(${JSON.stringify(payload)})
+});
+
+const data: Record = await response.json();
+console.log(data);`
+
+ default:
+ return ''
+ }
+ }
+
+ const getStreamCommand = (): string => {
+ if (!info) return ''
+ const endpoint = getBaseEndpoint()
+ const payload = getStreamPayloadObject()
+
+ switch (language) {
+ case 'curl':
+ return `curl -X POST \\
+ -H "X-API-Key: $SIM_API_KEY" \\
+ -H "Content-Type: application/json" \\
+ -d '${JSON.stringify(payload)}' \\
+ ${endpoint}`
+
+ case 'python':
+ return `import requests
+
+response = requests.post(
+ "${endpoint}",
+ headers={
+ "X-API-Key": SIM_API_KEY,
+ "Content-Type": "application/json"
+ },
+ json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')},
+ stream=True
+)
+
+for line in response.iter_lines():
+ if line:
+ print(line.decode("utf-8"))`
+
+ case 'javascript':
+ return `const response = await fetch("${endpoint}", {
+ method: "POST",
+ headers: {
+ "X-API-Key": SIM_API_KEY,
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify(${JSON.stringify(payload)})
+});
+
+const reader = response.body.getReader();
+const decoder = new TextDecoder();
+
+while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ console.log(decoder.decode(value));
+}`
+
+ case 'typescript':
+ return `const response = await fetch("${endpoint}", {
+ method: "POST",
+ headers: {
+ "X-API-Key": SIM_API_KEY,
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify(${JSON.stringify(payload)})
+});
+
+const reader = response.body!.getReader();
+const decoder = new TextDecoder();
+
+while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ console.log(decoder.decode(value));
+}`
+
+ default:
+ return ''
+ }
+ }
+
+ const getAsyncCommand = (): string => {
+ if (!info) return ''
+ const endpoint = getBaseEndpoint()
+ const baseUrl = endpoint.split('/api/workflows/')[0]
+ const payload = getPayloadObject()
+
+ switch (asyncExampleType) {
+ case 'execute':
+ switch (language) {
+ case 'curl':
+ return `curl -X POST \\
+ -H "X-API-Key: $SIM_API_KEY" \\
+ -H "Content-Type: application/json" \\
+ -H "X-Execution-Mode: async" \\
+ -d '${JSON.stringify(payload)}' \\
+ ${endpoint}`
+
+ case 'python':
+ return `import requests
+
+response = requests.post(
+ "${endpoint}",
+ headers={
+ "X-API-Key": SIM_API_KEY,
+ "Content-Type": "application/json",
+ "X-Execution-Mode": "async"
+ },
+ json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')}
+)
+
+job = response.json()
+print(job) # Contains job_id for status checking`
+
+ case 'javascript':
+ return `const response = await fetch("${endpoint}", {
+ method: "POST",
+ headers: {
+ "X-API-Key": SIM_API_KEY,
+ "Content-Type": "application/json",
+ "X-Execution-Mode": "async"
+ },
+ body: JSON.stringify(${JSON.stringify(payload)})
+});
+
+const job = await response.json();
+console.log(job); // Contains job_id for status checking`
+
+ case 'typescript':
+ return `const response = await fetch("${endpoint}", {
+ method: "POST",
+ headers: {
+ "X-API-Key": SIM_API_KEY,
+ "Content-Type": "application/json",
+ "X-Execution-Mode": "async"
+ },
+ body: JSON.stringify(${JSON.stringify(payload)})
+});
+
+const job: { job_id: string } = await response.json();
+console.log(job); // Contains job_id for status checking`
+
+ default:
+ return ''
+ }
+
+ case 'status':
+ switch (language) {
+ case 'curl':
+ return `curl -H "X-API-Key: $SIM_API_KEY" \\
+ ${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION`
+
+ case 'python':
+ return `import requests
+
+response = requests.get(
+ "${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION",
+ headers={"X-API-Key": SIM_API_KEY}
+)
+
+status = response.json()
+print(status)`
+
+ case 'javascript':
+ return `const response = await fetch(
+ "${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION",
+ {
+ headers: { "X-API-Key": SIM_API_KEY }
+ }
+);
+
+const status = await response.json();
+console.log(status);`
+
+ case 'typescript':
+ return `const response = await fetch(
+ "${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION",
+ {
+ headers: { "X-API-Key": SIM_API_KEY }
+ }
+);
+
+const status: Record = await response.json();
+console.log(status);`
+
+ default:
+ return ''
+ }
+
+ case 'rate-limits':
+ switch (language) {
+ case 'curl':
+ return `curl -H "X-API-Key: $SIM_API_KEY" \\
+ ${baseUrl}/api/users/me/usage-limits`
+
+ case 'python':
+ return `import requests
+
+response = requests.get(
+ "${baseUrl}/api/users/me/usage-limits",
+ headers={"X-API-Key": SIM_API_KEY}
+)
+
+limits = response.json()
+print(limits)`
+
+ case 'javascript':
+ return `const response = await fetch(
+ "${baseUrl}/api/users/me/usage-limits",
+ {
+ headers: { "X-API-Key": SIM_API_KEY }
+ }
+);
+
+const limits = await response.json();
+console.log(limits);`
+
+ case 'typescript':
+ return `const response = await fetch(
+ "${baseUrl}/api/users/me/usage-limits",
+ {
+ headers: { "X-API-Key": SIM_API_KEY }
+ }
+);
+
+const limits: Record = await response.json();
+console.log(limits);`
+
+ default:
+ return ''
+ }
+
+ default:
+ return ''
+ }
+ }
+
+ const getAsyncExampleTitle = () => {
+ switch (asyncExampleType) {
+ case 'execute':
+ return 'Execute Job'
+ case 'status':
+ return 'Check Status'
+ case 'rate-limits':
+ return 'Rate Limits'
+ default:
+ return 'Execute Job'
+ }
+ }
+
+ const handleCopy = (key: keyof CopiedState, value: string) => {
+ navigator.clipboard.writeText(value)
+ setCopied((prev) => ({ ...prev, [key]: true }))
+ setTimeout(() => setCopied((prev) => ({ ...prev, [key]: false })), 2000)
+ }
+
+ if (isLoading || !info) {
+ return (
+
+ {apiDeployError && (
+
+
API Deployment Error
+
{apiDeployError}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+ }
+
+ return (
+
+ {apiDeployError && (
+
+
API Deployment Error
+
{apiDeployError}
+
+ )}
+
+ {/*
+
+
+ URL
+
+
+
+ handleCopy('endpoint', info.endpoint)}
+ aria-label='Copy endpoint'
+ className='!p-1.5 -my-1.5'
+ >
+ {copied.endpoint ? (
+
+ ) : (
+
+ )}
+
+
+
+ {copied.endpoint ? 'Copied' : 'Copy'}
+
+
+
+
+
*/}
+
+
+
+
+ Language
+
+
+
+ {(Object.keys(LANGUAGE_LABELS) as CodeLanguage[]).map((lang, index, arr) => (
+ setLanguage(lang)}
+ className={`px-[8px] py-[4px] text-[12px] ${
+ index === 0
+ ? 'rounded-r-none'
+ : index === arr.length - 1
+ ? 'rounded-l-none'
+ : 'rounded-none'
+ }`}
+ >
+ {LANGUAGE_LABELS[lang]}
+
+ ))}
+
+
+
+
+
+
+ Run workflow
+
+
+
+ handleCopy('sync', getSyncCommand())}
+ aria-label='Copy command'
+ className='!p-1.5 -my-1.5'
+ >
+ {copied.sync ? : }
+
+
+
+ {copied.sync ? 'Copied' : 'Copy'}
+
+
+
+
+
+
+
+
+
+ Run workflow (stream response)
+
+
+
+
+ handleCopy('stream', getStreamCommand())}
+ aria-label='Copy command'
+ className='!p-1.5 -my-1.5'
+ >
+ {copied.stream ? (
+
+ ) : (
+
+ )}
+
+
+
+ {copied.stream ? 'Copied' : 'Copy'}
+
+
+
+
+
+
+
+
+ {isAsyncEnabled && (
+
+
+
+ Run workflow (async)
+
+
+
+
+ handleCopy('async', getAsyncCommand())}
+ aria-label='Copy command'
+ className='!p-1.5 -my-1.5'
+ >
+ {copied.async ? (
+
+ ) : (
+
+ )}
+
+
+
+ {copied.async ? 'Copied' : 'Copy'}
+
+
+
+
+
+
+
+ {getAsyncExampleTitle()}
+
+
+
+
+
+ setAsyncExampleType('execute')}
+ >
+ Execute Job
+
+ setAsyncExampleType('status')}
+ >
+ Check Status
+
+ setAsyncExampleType('rate-limits')}
+ >
+ Rate Limits
+
+
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/auth-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/auth-selector.tsx
deleted file mode 100644
index f5080afef..000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/auth-selector.tsx
+++ /dev/null
@@ -1,293 +0,0 @@
-import { useState } from 'react'
-import { Check, Copy, Eye, EyeOff, Plus, RefreshCw } from 'lucide-react'
-import { Button, Input, Label } from '@/components/emcn'
-import { Trash } from '@/components/emcn/icons/trash'
-import { Card, CardContent } from '@/components/ui'
-import { getEnv, isTruthy } from '@/lib/core/config/env'
-import { generatePassword } from '@/lib/core/security/encryption'
-import { cn } from '@/lib/core/utils/cn'
-import type { AuthType } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/hooks/use-chat-form'
-
-interface AuthSelectorProps {
- authType: AuthType
- password: string
- emails: string[]
- onAuthTypeChange: (type: AuthType) => void
- onPasswordChange: (password: string) => void
- onEmailsChange: (emails: string[]) => void
- disabled?: boolean
- isExistingChat?: boolean
- error?: string
-}
-
-export function AuthSelector({
- authType,
- password,
- emails,
- onAuthTypeChange,
- onPasswordChange,
- onEmailsChange,
- disabled = false,
- isExistingChat = false,
- error,
-}: AuthSelectorProps) {
- const [showPassword, setShowPassword] = useState(false)
- const [newEmail, setNewEmail] = useState('')
- const [emailError, setEmailError] = useState('')
- const [copySuccess, setCopySuccess] = useState(false)
-
- const handleGeneratePassword = () => {
- const password = generatePassword(24)
- onPasswordChange(password)
- }
-
- const copyToClipboard = (text: string) => {
- navigator.clipboard.writeText(text)
- setCopySuccess(true)
- setTimeout(() => setCopySuccess(false), 2000)
- }
-
- const handleAddEmail = () => {
- if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail) && !newEmail.startsWith('@')) {
- setEmailError('Please enter a valid email or domain (e.g., user@example.com or @example.com)')
- return
- }
-
- if (emails.includes(newEmail)) {
- setEmailError('This email or domain is already in the list')
- return
- }
-
- onEmailsChange([...emails, newEmail])
- setNewEmail('')
- setEmailError('')
- }
-
- const handleRemoveEmail = (email: string) => {
- onEmailsChange(emails.filter((e) => e !== email))
- }
-
- const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
- const authOptions = ssoEnabled
- ? (['public', 'password', 'email', 'sso'] as const)
- : (['public', 'password', 'email'] as const)
-
- return (
-
-
Access Control
-
- {/* Auth Type Selection */}
-
- {authOptions.map((type) => (
-
-
- !disabled && onAuthTypeChange(type)}
- aria-label={`Select ${type} access`}
- disabled={disabled}
- />
-
-
- {type === 'public' && 'Public Access'}
- {type === 'password' && 'Password Protected'}
- {type === 'email' && 'Email Access'}
- {type === 'sso' && 'SSO Access'}
-
-
- {type === 'public' && 'Anyone can access your chat'}
- {type === 'password' && 'Secure with a single password'}
- {type === 'email' && 'Restrict to specific emails'}
- {type === 'sso' && 'Authenticate via SSO provider'}
-
-
-
-
- ))}
-
-
- {/* Auth Settings */}
- {authType === 'password' && (
-
-
-
- Password Settings
-
-
- {isExistingChat && !password && (
-
-
- Password set
-
-
Current password is securely stored
-
- )}
-
-
-
onPasswordChange(e.target.value)}
- disabled={disabled}
- className='pr-28'
- required={!isExistingChat}
- autoComplete='new-password'
- />
-
-
-
- Generate password
-
- copyToClipboard(password)}
- disabled={!password || disabled}
- className='h-6 w-6 p-0'
- >
- {copySuccess ? (
-
- ) : (
-
- )}
- Copy password
-
- setShowPassword(!showPassword)}
- disabled={disabled}
- className='h-6 w-6 p-0'
- >
- {showPassword ? (
-
- ) : (
-
- )}
-
- {showPassword ? 'Hide password' : 'Show password'}
-
-
-
-
-
-
- {isExistingChat
- ? 'Leaving this empty will keep the current password. Enter a new password to change it.'
- : 'This password will be required to access your chat.'}
-
-
-
- )}
-
- {(authType === 'email' || authType === 'sso') && (
-
-
-
- {authType === 'email' ? 'Email Access Settings' : 'SSO Access Settings'}
-
-
-
-
setNewEmail(e.target.value)}
- disabled={disabled}
- className='flex-1'
- onKeyDown={(e) => {
- if (e.key === 'Enter') {
- e.preventDefault()
- handleAddEmail()
- }
- }}
- />
-
-
- Add
-
-
-
- {emailError && {emailError}
}
-
- {emails.length > 0 && (
-
- )}
-
-
- {authType === 'email'
- ? 'Add specific emails or entire domains (@example.com)'
- : 'Add specific emails or entire domains (@example.com) that can access via SSO'}
-
-
-
- )}
-
- {authType === 'public' && (
-
-
-
- Public Access Settings
-
-
- This chat will be publicly accessible to anyone with the link.
-
-
-
- )}
-
- {error &&
{error}
}
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat-deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat-deploy.tsx
deleted file mode 100644
index 11547a255..000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat-deploy.tsx
+++ /dev/null
@@ -1,486 +0,0 @@
-'use client'
-
-import { useEffect, useRef, useState } from 'react'
-import { AlertTriangle, Loader2 } from 'lucide-react'
-import {
- Button,
- Input,
- Label,
- Modal,
- ModalContent,
- ModalDescription,
- ModalFooter,
- ModalHeader,
- ModalTitle,
- Textarea,
-} from '@/components/emcn'
-import { Alert, AlertDescription, Skeleton } from '@/components/ui'
-import { getEmailDomain } from '@/lib/core/utils/urls'
-import { createLogger } from '@/lib/logs/console/logger'
-import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select'
-import { AuthSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/auth-selector'
-import { IdentifierInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/identifier-input'
-import { SuccessView } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/success-view'
-import { useChatDeployment } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/hooks/use-chat-deployment'
-import { useChatForm } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/hooks/use-chat-form'
-
-const logger = createLogger('ChatDeploy')
-
-interface ChatDeployProps {
- workflowId: string
- deploymentInfo: {
- apiKey: string
- } | null
- onChatExistsChange?: (exists: boolean) => void
- chatSubmitting: boolean
- setChatSubmitting: (submitting: boolean) => void
- onValidationChange?: (isValid: boolean) => void
- showDeleteConfirmation?: boolean
- setShowDeleteConfirmation?: (show: boolean) => void
- onDeploymentComplete?: () => void
- onDeployed?: () => void
- onUndeploy?: () => Promise
- onVersionActivated?: () => void
-}
-
-interface ExistingChat {
- id: string
- identifier: string
- title: string
- description: string
- authType: 'public' | 'password' | 'email'
- allowedEmails: string[]
- outputConfigs: Array<{ blockId: string; path: string }>
- customizations?: {
- welcomeMessage?: string
- }
- isActive: boolean
-}
-
-export function ChatDeploy({
- workflowId,
- deploymentInfo,
- onChatExistsChange,
- chatSubmitting,
- setChatSubmitting,
- onValidationChange,
- showDeleteConfirmation: externalShowDeleteConfirmation,
- setShowDeleteConfirmation: externalSetShowDeleteConfirmation,
- onDeploymentComplete,
- onDeployed,
- onUndeploy,
- onVersionActivated,
-}: ChatDeployProps) {
- const [isLoading, setIsLoading] = useState(false)
- const [existingChat, setExistingChat] = useState(null)
- const [imageUrl, setImageUrl] = useState(null)
- const [imageUploadError, setImageUploadError] = useState(null)
- const [isDeleting, setIsDeleting] = useState(false)
- const [isImageUploading, setIsImageUploading] = useState(false)
- const [internalShowDeleteConfirmation, setInternalShowDeleteConfirmation] = useState(false)
- const [showSuccessView, setShowSuccessView] = useState(false)
-
- // Use external state for delete confirmation if provided
- const showDeleteConfirmation =
- externalShowDeleteConfirmation !== undefined
- ? externalShowDeleteConfirmation
- : internalShowDeleteConfirmation
-
- const setShowDeleteConfirmation =
- externalSetShowDeleteConfirmation || setInternalShowDeleteConfirmation
-
- const { formData, errors, updateField, setError, validateForm, setFormData } = useChatForm()
- const { deployedUrl, deployChat } = useChatDeployment()
- const formRef = useRef(null)
- const [isIdentifierValid, setIsIdentifierValid] = useState(false)
-
- const isFormValid =
- isIdentifierValid &&
- Boolean(formData.title.trim()) &&
- formData.selectedOutputBlocks.length > 0 &&
- (formData.authType !== 'password' ||
- Boolean(formData.password.trim()) ||
- Boolean(existingChat)) &&
- ((formData.authType !== 'email' && formData.authType !== 'sso') || formData.emails.length > 0)
-
- useEffect(() => {
- onValidationChange?.(isFormValid)
- }, [isFormValid, onValidationChange])
-
- useEffect(() => {
- if (workflowId) {
- fetchExistingChat()
- }
- }, [workflowId])
-
- const fetchExistingChat = async () => {
- try {
- setIsLoading(true)
- const response = await fetch(`/api/workflows/${workflowId}/chat/status`)
-
- if (response.ok) {
- const data = await response.json()
-
- if (data.isDeployed && data.deployment) {
- const detailResponse = await fetch(`/api/chat/manage/${data.deployment.id}`)
-
- if (detailResponse.ok) {
- const chatDetail = await detailResponse.json()
- setExistingChat(chatDetail)
-
- setFormData({
- identifier: chatDetail.identifier || '',
- title: chatDetail.title || '',
- description: chatDetail.description || '',
- authType: chatDetail.authType || 'public',
- password: '',
- emails: Array.isArray(chatDetail.allowedEmails) ? [...chatDetail.allowedEmails] : [],
- welcomeMessage:
- chatDetail.customizations?.welcomeMessage || 'Hi there! How can I help you today?',
- selectedOutputBlocks: Array.isArray(chatDetail.outputConfigs)
- ? chatDetail.outputConfigs.map(
- (config: { blockId: string; path: string }) =>
- `${config.blockId}_${config.path}`
- )
- : [],
- })
-
- if (chatDetail.customizations?.imageUrl) {
- setImageUrl(chatDetail.customizations.imageUrl)
- }
- setImageUploadError(null)
-
- onChatExistsChange?.(true)
- }
- } else {
- setExistingChat(null)
- setImageUrl(null)
- setImageUploadError(null)
- onChatExistsChange?.(false)
- }
- }
- } catch (error) {
- logger.error('Error fetching chat status:', error)
- } finally {
- setIsLoading(false)
- }
- }
-
- const handleSubmit = async (e?: React.FormEvent) => {
- if (e) e.preventDefault()
-
- if (chatSubmitting) return
-
- setChatSubmitting(true)
-
- try {
- if (!validateForm()) {
- setChatSubmitting(false)
- return
- }
-
- if (!isIdentifierValid && formData.identifier !== existingChat?.identifier) {
- setError('identifier', 'Please wait for identifier validation to complete')
- setChatSubmitting(false)
- return
- }
-
- await deployChat(workflowId, formData, null, existingChat?.id, imageUrl)
-
- onChatExistsChange?.(true)
- setShowSuccessView(true)
- onDeployed?.()
- onVersionActivated?.()
-
- await fetchExistingChat()
- } catch (error: any) {
- if (error.message?.includes('identifier')) {
- setError('identifier', error.message)
- } else {
- setError('general', error.message)
- }
- } finally {
- setChatSubmitting(false)
- }
- }
-
- const handleDelete = async () => {
- if (!existingChat || !existingChat.id) return
-
- try {
- setIsDeleting(true)
-
- const response = await fetch(`/api/chat/manage/${existingChat.id}`, {
- method: 'DELETE',
- })
-
- if (!response.ok) {
- const error = await response.json()
- throw new Error(error.error || 'Failed to delete chat')
- }
-
- setExistingChat(null)
- setImageUrl(null)
- setImageUploadError(null)
- onChatExistsChange?.(false)
-
- onDeploymentComplete?.()
- } catch (error: any) {
- logger.error('Failed to delete chat:', error)
- setError('general', error.message || 'An unexpected error occurred while deleting')
- } finally {
- setIsDeleting(false)
- setShowDeleteConfirmation(false)
- }
- }
-
- if (isLoading) {
- return
- }
-
- if (deployedUrl && showSuccessView) {
- return (
- <>
-
- setShowDeleteConfirmation(true)}
- onUpdate={() => setShowSuccessView(false)}
- />
-
-
- {/* Delete Confirmation Dialog */}
-
-
-
- Delete Chat?
-
- This will delete your chat deployment at "{getEmailDomain()}/chat/
- {existingChat?.identifier}". All users will lose access to the chat interface. You
- can recreate this chat deployment at any time.
-
-
-
- setShowDeleteConfirmation(false)}
- disabled={isDeleting}
- >
- Cancel
-
-
- {isDeleting ? (
- <>
-
- Deleting...
- >
- ) : (
- 'Delete'
- )}
-
-
-
-
- >
- )
- }
-
- return (
- <>
-
-
-
-
-
- Delete Chat?
-
- This will delete your chat deployment at "{getEmailDomain()}/chat/
- {existingChat?.identifier}". All users will lose access to the chat interface. You can
- recreate this chat deployment at any time.
-
-
-
- setShowDeleteConfirmation(false)}
- disabled={isDeleting}
- >
- Cancel
-
-
- {isDeleting ? (
- <>
-
- Deleting...
- >
- ) : (
- 'Delete'
- )}
-
-
-
-
- >
- )
-}
-
-function LoadingSkeleton() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx
new file mode 100644
index 000000000..889e2e4fe
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx
@@ -0,0 +1,871 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import { AlertTriangle, Check, Clipboard, Eye, EyeOff, Loader2, RefreshCw, X } from 'lucide-react'
+import { Button, Input, Label, Textarea, Tooltip } from '@/components/emcn'
+import {
+ Modal,
+ ModalBody,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+} from '@/components/emcn/components/modal/modal'
+import { Alert, AlertDescription, Skeleton } from '@/components/ui'
+import { getEnv, isTruthy } from '@/lib/core/config/env'
+import { generatePassword } from '@/lib/core/security/encryption'
+import { cn } from '@/lib/core/utils/cn'
+import { getEmailDomain } from '@/lib/core/utils/urls'
+import { createLogger } from '@/lib/logs/console/logger'
+import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select'
+import {
+ type AuthType,
+ type ChatFormData,
+ useChatDeployment,
+ useIdentifierValidation,
+} from './hooks'
+
+const logger = createLogger('ChatDeploy')
+
+const IDENTIFIER_PATTERN = /^[a-z0-9-]+$/
+
+interface ChatDeployProps {
+ workflowId: string
+ deploymentInfo: {
+ apiKey: string
+ } | null
+ existingChat: ExistingChat | null
+ isLoadingChat: boolean
+ onRefetchChat: () => Promise
+ onChatExistsChange?: (exists: boolean) => void
+ chatSubmitting: boolean
+ setChatSubmitting: (submitting: boolean) => void
+ onValidationChange?: (isValid: boolean) => void
+ showDeleteConfirmation?: boolean
+ setShowDeleteConfirmation?: (show: boolean) => void
+ onDeploymentComplete?: () => void
+ onDeployed?: () => void
+ onVersionActivated?: () => void
+}
+
+export interface ExistingChat {
+ id: string
+ identifier: string
+ title: string
+ description: string
+ authType: 'public' | 'password' | 'email' | 'sso'
+ allowedEmails: string[]
+ outputConfigs: Array<{ blockId: string; path: string }>
+ customizations?: {
+ welcomeMessage?: string
+ imageUrl?: string
+ }
+ isActive: boolean
+}
+
+interface FormErrors {
+ identifier?: string
+ title?: string
+ password?: string
+ emails?: string
+ outputBlocks?: string
+ general?: string
+}
+
+const initialFormData: ChatFormData = {
+ identifier: '',
+ title: '',
+ description: '',
+ authType: 'public',
+ password: '',
+ emails: [],
+ welcomeMessage: 'Hi there! How can I help you today?',
+ selectedOutputBlocks: [],
+}
+
+export function ChatDeploy({
+ workflowId,
+ deploymentInfo,
+ existingChat,
+ isLoadingChat,
+ onRefetchChat,
+ onChatExistsChange,
+ chatSubmitting,
+ setChatSubmitting,
+ onValidationChange,
+ showDeleteConfirmation: externalShowDeleteConfirmation,
+ setShowDeleteConfirmation: externalSetShowDeleteConfirmation,
+ onDeploymentComplete,
+ onDeployed,
+ onVersionActivated,
+}: ChatDeployProps) {
+ const [imageUrl, setImageUrl] = useState(null)
+ const [isDeleting, setIsDeleting] = useState(false)
+ const [internalShowDeleteConfirmation, setInternalShowDeleteConfirmation] = useState(false)
+
+ const showDeleteConfirmation =
+ externalShowDeleteConfirmation !== undefined
+ ? externalShowDeleteConfirmation
+ : internalShowDeleteConfirmation
+
+ const setShowDeleteConfirmation =
+ externalSetShowDeleteConfirmation || setInternalShowDeleteConfirmation
+
+ const [formData, setFormData] = useState(initialFormData)
+ const [errors, setErrors] = useState({})
+ const { deployChat } = useChatDeployment()
+ const formRef = useRef(null)
+ const [isIdentifierValid, setIsIdentifierValid] = useState(false)
+ const [hasInitializedForm, setHasInitializedForm] = useState(false)
+
+ const updateField = (field: K, value: ChatFormData[K]) => {
+ setFormData((prev) => ({ ...prev, [field]: value }))
+ if (errors[field as keyof FormErrors]) {
+ setErrors((prev) => ({ ...prev, [field]: undefined }))
+ }
+ }
+
+ const setError = (field: keyof FormErrors, message: string) => {
+ setErrors((prev) => ({ ...prev, [field]: message }))
+ }
+
+ const validateForm = (isExistingChat: boolean): boolean => {
+ const newErrors: FormErrors = {}
+
+ if (!formData.identifier.trim()) {
+ newErrors.identifier = 'Identifier is required'
+ } else if (!IDENTIFIER_PATTERN.test(formData.identifier)) {
+ newErrors.identifier = 'Identifier can only contain lowercase letters, numbers, and hyphens'
+ }
+
+ if (!formData.title.trim()) {
+ newErrors.title = 'Title is required'
+ }
+
+ if (formData.authType === 'password' && !isExistingChat && !formData.password.trim()) {
+ newErrors.password = 'Password is required when using password protection'
+ }
+
+ if (
+ (formData.authType === 'email' || formData.authType === 'sso') &&
+ formData.emails.length === 0
+ ) {
+ newErrors.emails = `At least one email or domain is required when using ${formData.authType === 'sso' ? 'SSO' : 'email'} access control`
+ }
+
+ if (formData.selectedOutputBlocks.length === 0) {
+ newErrors.outputBlocks = 'Please select at least one output block'
+ }
+
+ setErrors(newErrors)
+ return Object.keys(newErrors).length === 0
+ }
+
+ const isFormValid =
+ isIdentifierValid &&
+ Boolean(formData.title.trim()) &&
+ formData.selectedOutputBlocks.length > 0 &&
+ (formData.authType !== 'password' ||
+ Boolean(formData.password.trim()) ||
+ Boolean(existingChat)) &&
+ ((formData.authType !== 'email' && formData.authType !== 'sso') || formData.emails.length > 0)
+
+ useEffect(() => {
+ onValidationChange?.(isFormValid)
+ }, [isFormValid, onValidationChange])
+
+ useEffect(() => {
+ if (existingChat && !hasInitializedForm) {
+ setFormData({
+ identifier: existingChat.identifier || '',
+ title: existingChat.title || '',
+ description: existingChat.description || '',
+ authType: existingChat.authType || 'public',
+ password: '',
+ emails: Array.isArray(existingChat.allowedEmails) ? [...existingChat.allowedEmails] : [],
+ welcomeMessage:
+ existingChat.customizations?.welcomeMessage || 'Hi there! How can I help you today?',
+ selectedOutputBlocks: Array.isArray(existingChat.outputConfigs)
+ ? existingChat.outputConfigs.map(
+ (config: { blockId: string; path: string }) => `${config.blockId}_${config.path}`
+ )
+ : [],
+ })
+
+ if (existingChat.customizations?.imageUrl) {
+ setImageUrl(existingChat.customizations.imageUrl)
+ }
+
+ setHasInitializedForm(true)
+ } else if (!existingChat && !isLoadingChat) {
+ setFormData(initialFormData)
+ setImageUrl(null)
+ setHasInitializedForm(false)
+ }
+ }, [existingChat, isLoadingChat, hasInitializedForm])
+
+ const handleSubmit = async (e?: React.FormEvent) => {
+ if (e) e.preventDefault()
+
+ if (chatSubmitting) return
+
+ setChatSubmitting(true)
+
+ try {
+ if (!validateForm(!!existingChat)) {
+ setChatSubmitting(false)
+ return
+ }
+
+ if (!isIdentifierValid && formData.identifier !== existingChat?.identifier) {
+ setError('identifier', 'Please wait for identifier validation to complete')
+ setChatSubmitting(false)
+ return
+ }
+
+ const chatUrl = await deployChat(
+ workflowId,
+ formData,
+ deploymentInfo,
+ existingChat?.id,
+ imageUrl
+ )
+
+ onChatExistsChange?.(true)
+ onDeployed?.()
+ onVersionActivated?.()
+
+ if (chatUrl) {
+ window.open(chatUrl, '_blank', 'noopener,noreferrer')
+ }
+
+ setHasInitializedForm(false)
+ await onRefetchChat()
+ } catch (error: any) {
+ if (error.message?.includes('identifier')) {
+ setError('identifier', error.message)
+ } else {
+ setError('general', error.message)
+ }
+ } finally {
+ setChatSubmitting(false)
+ }
+ }
+
+ const handleDelete = async () => {
+ if (!existingChat || !existingChat.id) return
+
+ try {
+ setIsDeleting(true)
+
+ const response = await fetch(`/api/chat/manage/${existingChat.id}`, {
+ method: 'DELETE',
+ })
+
+ if (!response.ok) {
+ const error = await response.json()
+ throw new Error(error.error || 'Failed to delete chat')
+ }
+
+ setImageUrl(null)
+ setHasInitializedForm(false)
+ onChatExistsChange?.(false)
+ await onRefetchChat()
+
+ onDeploymentComplete?.()
+ } catch (error: any) {
+ logger.error('Failed to delete chat:', error)
+ setError('general', error.message || 'An unexpected error occurred while deleting')
+ } finally {
+ setIsDeleting(false)
+ setShowDeleteConfirmation(false)
+ }
+ }
+
+ if (isLoadingChat) {
+ return
+ }
+
+ return (
+ <>
+
+ {errors.general && (
+
+
+ {errors.general}
+
+ )}
+
+
+
updateField('identifier', value)}
+ originalIdentifier={existingChat?.identifier || undefined}
+ disabled={chatSubmitting}
+ onValidationChange={setIsIdentifierValid}
+ isEditingExisting={!!existingChat}
+ />
+
+
+
+ Title
+
+
updateField('title', e.target.value)}
+ required
+ disabled={chatSubmitting}
+ />
+ {errors.title &&
{errors.title}
}
+
+
+
+
+ Output
+
+
updateField('selectedOutputBlocks', values)}
+ placeholder='Select which block outputs to use'
+ disabled={chatSubmitting}
+ />
+ {errors.outputBlocks && (
+ {errors.outputBlocks}
+ )}
+
+
+ updateField('authType', type)}
+ onPasswordChange={(password) => updateField('password', password)}
+ onEmailsChange={(emails) => updateField('emails', emails)}
+ disabled={chatSubmitting}
+ isExistingChat={!!existingChat}
+ error={errors.password || errors.emails}
+ />
+
+
+ Welcome message
+
+
updateField('welcomeMessage', e.target.value)}
+ rows={3}
+ disabled={chatSubmitting}
+ className='min-h-[80px] resize-none'
+ />
+
+ This message will be displayed when users first open the chat
+
+
+
+ setShowDeleteConfirmation(true)}
+ style={{ display: 'none' }}
+ />
+
+
+
+
+
+ Delete Chat
+
+
+ Are you sure you want to delete this chat?{' '}
+
+ This will remove the chat at "{getEmailDomain()}/chat/{existingChat?.identifier}"
+ and make it unavailable to all users.
+
+
+
+
+ setShowDeleteConfirmation(false)}
+ disabled={isDeleting}
+ >
+ Cancel
+
+
+ {isDeleting && }
+ {isDeleting ? 'Deleting...' : 'Delete'}
+
+
+
+
+ >
+ )
+}
+
+function LoadingSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+interface IdentifierInputProps {
+ value: string
+ onChange: (value: string) => void
+ originalIdentifier?: string
+ disabled?: boolean
+ onValidationChange?: (isValid: boolean) => void
+ isEditingExisting?: boolean
+}
+
+const getDomainPrefix = (() => {
+ const prefix = `${getEmailDomain()}/chat/`
+ return () => prefix
+})()
+
+function IdentifierInput({
+ value,
+ onChange,
+ originalIdentifier,
+ disabled = false,
+ onValidationChange,
+ isEditingExisting = false,
+}: IdentifierInputProps) {
+ const { isChecking, error, isValid } = useIdentifierValidation(
+ value,
+ originalIdentifier,
+ isEditingExisting
+ )
+
+ useEffect(() => {
+ onValidationChange?.(isValid)
+ }, [isValid, onValidationChange])
+
+ const handleChange = (newValue: string) => {
+ const lowercaseValue = newValue.toLowerCase()
+ onChange(lowercaseValue)
+ }
+
+ const fullUrl = `${getEnv('NEXT_PUBLIC_APP_URL')}/chat/${value}`
+ const displayUrl = fullUrl.replace(/^https?:\/\//, '')
+
+ return (
+
+
+ URL
+
+
+
+ {getDomainPrefix()}
+
+
+
handleChange(e.target.value)}
+ required
+ disabled={disabled}
+ className={cn(
+ 'rounded-none border-0 pl-0 shadow-none disabled:bg-transparent disabled:opacity-100',
+ isChecking && 'pr-[32px]'
+ )}
+ />
+ {isChecking && (
+
+
+
+ )}
+
+
+ {error &&
{error}
}
+
+ {isEditingExisting && value ? (
+ <>
+ Live at:{' '}
+
+ {displayUrl}
+
+ >
+ ) : (
+ 'The unique URL path where your chat will be accessible'
+ )}
+
+
+ )
+}
+
+interface AuthSelectorProps {
+ authType: AuthType
+ password: string
+ emails: string[]
+ onAuthTypeChange: (type: AuthType) => void
+ onPasswordChange: (password: string) => void
+ onEmailsChange: (emails: string[]) => void
+ disabled?: boolean
+ isExistingChat?: boolean
+ error?: string
+}
+
+const AUTH_LABELS: Record = {
+ public: 'Public',
+ password: 'Password',
+ email: 'Email',
+ sso: 'SSO',
+}
+
+function AuthSelector({
+ authType,
+ password,
+ emails,
+ onAuthTypeChange,
+ onPasswordChange,
+ onEmailsChange,
+ disabled = false,
+ isExistingChat = false,
+ error,
+}: AuthSelectorProps) {
+ const [showPassword, setShowPassword] = useState(false)
+ const [emailInputValue, setEmailInputValue] = useState('')
+ const [emailError, setEmailError] = useState('')
+ const [copySuccess, setCopySuccess] = useState(false)
+ const [invalidEmails, setInvalidEmails] = useState([])
+
+ const handleGeneratePassword = () => {
+ const newPassword = generatePassword(24)
+ onPasswordChange(newPassword)
+ }
+
+ const copyToClipboard = (text: string) => {
+ navigator.clipboard.writeText(text)
+ setCopySuccess(true)
+ setTimeout(() => setCopySuccess(false), 2000)
+ }
+
+ const addEmail = (email: string): boolean => {
+ if (!email.trim()) return false
+
+ const normalized = email.trim().toLowerCase()
+ const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized) || normalized.startsWith('@')
+
+ if (emails.includes(normalized) || invalidEmails.includes(normalized)) {
+ return false
+ }
+
+ if (!isValid) {
+ setInvalidEmails((prev) => [...prev, normalized])
+ setEmailInputValue('')
+ return false
+ }
+
+ setEmailError('')
+ onEmailsChange([...emails, normalized])
+ setEmailInputValue('')
+ return true
+ }
+
+ const handleRemoveEmail = (emailToRemove: string) => {
+ onEmailsChange(emails.filter((e) => e !== emailToRemove))
+ }
+
+ const handleRemoveInvalidEmail = (index: number) => {
+ setInvalidEmails((prev) => prev.filter((_, i) => i !== index))
+ }
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (['Enter', ',', ' '].includes(e.key) && emailInputValue.trim()) {
+ e.preventDefault()
+ addEmail(emailInputValue)
+ }
+
+ if (e.key === 'Backspace' && !emailInputValue) {
+ if (invalidEmails.length > 0) {
+ handleRemoveInvalidEmail(invalidEmails.length - 1)
+ } else if (emails.length > 0) {
+ handleRemoveEmail(emails[emails.length - 1])
+ }
+ }
+ }
+
+ const handlePaste = (e: React.ClipboardEvent) => {
+ e.preventDefault()
+ const pastedText = e.clipboardData.getData('text')
+ const pastedEmails = pastedText.split(/[\s,;]+/).filter(Boolean)
+
+ let addedCount = 0
+ pastedEmails.forEach((email) => {
+ if (addEmail(email)) {
+ addedCount++
+ }
+ })
+
+ if (addedCount === 0 && pastedEmails.length === 1) {
+ setEmailInputValue(emailInputValue + pastedEmails[0])
+ }
+ }
+
+ const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
+ const authOptions = ssoEnabled
+ ? (['public', 'password', 'email', 'sso'] as const)
+ : (['public', 'password', 'email'] as const)
+
+ return (
+
+
+
+ Access control
+
+
+ {authOptions.map((type, index, arr) => (
+ !disabled && onAuthTypeChange(type)}
+ disabled={disabled}
+ className={`px-[8px] py-[4px] text-[12px] ${
+ index === 0
+ ? 'rounded-r-none'
+ : index === arr.length - 1
+ ? 'rounded-l-none'
+ : 'rounded-none'
+ }`}
+ >
+ {AUTH_LABELS[type]}
+
+ ))}
+
+
+
+ {authType === 'password' && (
+
+
+ Password
+
+
+
onPasswordChange(e.target.value)}
+ disabled={disabled}
+ className='pr-[88px]'
+ required={!isExistingChat}
+ autoComplete='new-password'
+ />
+
+
+
+
+
+
+
+
+ Generate
+
+
+
+
+ copyToClipboard(password)}
+ disabled={!password || disabled}
+ aria-label='Copy password'
+ className='!p-1.5'
+ >
+ {copySuccess ? (
+
+ ) : (
+
+ )}
+
+
+
+ {copySuccess ? 'Copied' : 'Copy'}
+
+
+
+
+ setShowPassword(!showPassword)}
+ disabled={disabled}
+ aria-label={showPassword ? 'Hide password' : 'Show password'}
+ className='!p-1.5'
+ >
+ {showPassword ? : }
+
+
+
+ {showPassword ? 'Hide' : 'Show'}
+
+
+
+
+
+ {isExistingChat
+ ? 'Leave empty to keep the current password'
+ : 'This password will be required to access your chat'}
+
+
+ )}
+
+ {(authType === 'email' || authType === 'sso') && (
+
+
+ {authType === 'email' ? 'Allowed emails' : 'Allowed SSO emails'}
+
+
+ {invalidEmails.map((email, index) => (
+ handleRemoveInvalidEmail(index)}
+ disabled={disabled}
+ isInvalid={true}
+ />
+ ))}
+ {emails.map((email, index) => (
+ handleRemoveEmail(email)}
+ disabled={disabled}
+ />
+ ))}
+ setEmailInputValue(e.target.value)}
+ onKeyDown={handleKeyDown}
+ onPaste={handlePaste}
+ onBlur={() => emailInputValue.trim() && addEmail(emailInputValue)}
+ placeholder={
+ emails.length > 0 || invalidEmails.length > 0
+ ? 'Add another email'
+ : 'Enter emails or domains (@example.com)'
+ }
+ className={cn(
+ 'h-6 min-w-[180px] flex-1 border-none bg-transparent p-0 text-[13px] focus-visible:ring-0 focus-visible:ring-offset-0',
+ emails.length > 0 || invalidEmails.length > 0 ? 'pl-[4px]' : 'pl-[4px]'
+ )}
+ disabled={disabled}
+ />
+
+ {emailError && (
+
{emailError}
+ )}
+
+ {authType === 'email'
+ ? 'Add specific emails or entire domains (@example.com)'
+ : 'Add emails or domains that can access via SSO'}
+
+
+ )}
+
+ {error &&
{error}
}
+
+ )
+}
+
+interface EmailTagProps {
+ email: string
+ onRemove: () => void
+ disabled?: boolean
+ isInvalid?: boolean
+}
+
+function EmailTag({ email, onRemove, disabled, isInvalid }: EmailTagProps) {
+ return (
+
+ {email}
+ {!disabled && (
+
+
+
+ )}
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/hooks/index.ts
new file mode 100644
index 000000000..34a3694b5
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/hooks/index.ts
@@ -0,0 +1,2 @@
+export { type AuthType, type ChatFormData, useChatDeployment } from './use-chat-deployment'
+export { useIdentifierValidation } from './use-identifier-validation'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/hooks/use-chat-deployment.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/hooks/use-chat-deployment.ts
new file mode 100644
index 000000000..d7eda4084
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/hooks/use-chat-deployment.ts
@@ -0,0 +1,131 @@
+import { useCallback } from 'react'
+import { z } from 'zod'
+import { createLogger } from '@/lib/logs/console/logger'
+import type { OutputConfig } from '@/stores/chat/store'
+
+const logger = createLogger('ChatDeployment')
+
+export type AuthType = 'public' | 'password' | 'email' | 'sso'
+
+export interface ChatFormData {
+ identifier: string
+ title: string
+ description: string
+ authType: AuthType
+ password: string
+ emails: string[]
+ welcomeMessage: string
+ selectedOutputBlocks: string[]
+}
+
+const chatSchema = z.object({
+ workflowId: z.string().min(1, 'Workflow ID is required'),
+ identifier: z
+ .string()
+ .min(1, 'Identifier is required')
+ .regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens'),
+ title: z.string().min(1, 'Title is required'),
+ description: z.string().optional(),
+ customizations: z.object({
+ primaryColor: z.string(),
+ welcomeMessage: z.string(),
+ imageUrl: z.string().optional(),
+ }),
+ authType: z.enum(['public', 'password', 'email', 'sso']).default('public'),
+ password: z.string().optional(),
+ allowedEmails: z.array(z.string()).optional().default([]),
+ outputConfigs: z
+ .array(
+ z.object({
+ blockId: z.string(),
+ path: z.string(),
+ })
+ )
+ .optional()
+ .default([]),
+})
+
+/**
+ * Parses output block selections into structured output configs
+ */
+function parseOutputConfigs(selectedOutputBlocks: string[]): OutputConfig[] {
+ return selectedOutputBlocks
+ .map((outputId) => {
+ const firstUnderscoreIndex = outputId.indexOf('_')
+ if (firstUnderscoreIndex !== -1) {
+ const blockId = outputId.substring(0, firstUnderscoreIndex)
+ const path = outputId.substring(firstUnderscoreIndex + 1)
+ if (blockId && path) {
+ return { blockId, path }
+ }
+ }
+ return null
+ })
+ .filter((config): config is OutputConfig => config !== null)
+}
+
+/**
+ * Hook for deploying or updating a chat interface
+ */
+export function useChatDeployment() {
+ const deployChat = useCallback(
+ async (
+ workflowId: string,
+ formData: ChatFormData,
+ deploymentInfo: { apiKey: string } | null,
+ existingChatId?: string,
+ imageUrl?: string | null
+ ): Promise => {
+ const outputConfigs = parseOutputConfigs(formData.selectedOutputBlocks)
+
+ const payload = {
+ workflowId,
+ identifier: formData.identifier.trim(),
+ title: formData.title.trim(),
+ description: formData.description.trim(),
+ customizations: {
+ primaryColor: 'var(--brand-primary-hover-hex)',
+ welcomeMessage: formData.welcomeMessage.trim(),
+ ...(imageUrl && { imageUrl }),
+ },
+ authType: formData.authType,
+ password: formData.authType === 'password' ? formData.password : undefined,
+ allowedEmails:
+ formData.authType === 'email' || formData.authType === 'sso' ? formData.emails : [],
+ outputConfigs,
+ apiKey: deploymentInfo?.apiKey,
+ deployApiEnabled: !existingChatId,
+ }
+
+ chatSchema.parse(payload)
+
+ const endpoint = existingChatId ? `/api/chat/manage/${existingChatId}` : '/api/chat'
+ const method = existingChatId ? 'PATCH' : 'POST'
+
+ const response = await fetch(endpoint, {
+ method,
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ })
+
+ const result = await response.json()
+
+ if (!response.ok) {
+ if (result.error === 'Identifier already in use') {
+ throw new Error('This identifier is already in use')
+ }
+ throw new Error(result.error || `Failed to ${existingChatId ? 'update' : 'deploy'} chat`)
+ }
+
+ if (!result.chatUrl) {
+ throw new Error('Response missing chatUrl')
+ }
+
+ logger.info(`Chat ${existingChatId ? 'updated' : 'deployed'} successfully:`, result.chatUrl)
+ return result.chatUrl
+ },
+ []
+ )
+
+ return { deployChat }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-identifier-validation.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/hooks/use-identifier-validation.ts
similarity index 75%
rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-identifier-validation.ts
rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/hooks/use-identifier-validation.ts
index 3c9b675fb..a7d78d47a 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-identifier-validation.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/hooks/use-identifier-validation.ts
@@ -1,10 +1,25 @@
import { useEffect, useRef, useState } from 'react'
+const IDENTIFIER_PATTERN = /^[a-z0-9-]+$/
+const DEBOUNCE_MS = 500
+
+interface IdentifierValidationState {
+ isChecking: boolean
+ error: string | null
+ isValid: boolean
+}
+
+/**
+ * Hook for validating chat identifier availability with debounced API checks
+ * @param identifier - The identifier to validate
+ * @param originalIdentifier - The original identifier when editing an existing chat
+ * @param isEditingExisting - Whether we're editing an existing chat deployment
+ */
export function useIdentifierValidation(
identifier: string,
originalIdentifier?: string,
isEditingExisting?: boolean
-) {
+): IdentifierValidationState {
const [isChecking, setIsChecking] = useState(false)
const [error, setError] = useState(null)
const [isValid, setIsValid] = useState(false)
@@ -16,36 +31,29 @@ export function useIdentifierValidation(
clearTimeout(timeoutRef.current)
}
- // Reset states immediately when identifier changes
setError(null)
setIsValid(false)
setIsChecking(false)
- // Skip validation if empty
if (!identifier.trim()) {
return
}
- // Skip validation if same as original (existing deployment)
if (originalIdentifier && identifier === originalIdentifier) {
setIsValid(true)
return
}
- // If we're editing an existing deployment but originalIdentifier isn't available yet,
- // assume it's valid and wait for the data to load
if (isEditingExisting && !originalIdentifier) {
setIsValid(true)
return
}
- // Validate format first - client-side validation
- if (!/^[a-z0-9-]+$/.test(identifier)) {
+ if (!IDENTIFIER_PATTERN.test(identifier)) {
setError('Identifier can only contain lowercase letters, numbers, and hyphens')
return
}
- // Check availability with server
setIsChecking(true)
timeoutRef.current = setTimeout(async () => {
try {
@@ -64,13 +72,13 @@ export function useIdentifierValidation(
setError(null)
setIsValid(true)
}
- } catch (error) {
+ } catch {
setError('Error checking identifier availability')
setIsValid(false)
} finally {
setIsChecking(false)
}
- }, 500)
+ }, DEBOUNCE_MS)
return () => {
if (timeoutRef.current) {
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-status.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-status.tsx
deleted file mode 100644
index 2a5e73330..000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-status.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-'use client'
-
-import { cn } from '@/lib/core/utils/cn'
-
-interface DeployStatusProps {
- needsRedeployment: boolean
-}
-
-export function DeployStatus({ needsRedeployment }: DeployStatusProps) {
- return (
-
-
Status:
-
-
- {needsRedeployment ? (
- <>
-
-
- >
- ) : (
- <>
-
-
- >
- )}
-
-
- {needsRedeployment ? 'Changes Detected' : 'Active'}
-
-
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deployed-workflow-card.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deployed-workflow-card.tsx
deleted file mode 100644
index 2d68a5d74..000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deployed-workflow-card.tsx
+++ /dev/null
@@ -1,121 +0,0 @@
-'use client'
-
-import { useMemo, useState } from 'react'
-import { Card, CardContent, CardHeader } from '@/components/ui/card'
-import { cn } from '@/lib/core/utils/cn'
-import { createLogger } from '@/lib/logs/console/logger'
-import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
-import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
-import type { WorkflowState } from '@/stores/workflows/workflow/types'
-
-const logger = createLogger('DeployedWorkflowCard')
-
-interface DeployedWorkflowCardProps {
- currentWorkflowState?: WorkflowState
- activeDeployedWorkflowState?: WorkflowState
- selectedDeployedWorkflowState?: WorkflowState
- selectedVersionLabel?: string
- className?: string
-}
-
-export function DeployedWorkflowCard({
- currentWorkflowState,
- activeDeployedWorkflowState,
- selectedDeployedWorkflowState,
- selectedVersionLabel,
- className,
-}: DeployedWorkflowCardProps) {
- type View = 'current' | 'active' | 'selected'
- const hasCurrent = !!currentWorkflowState
- const hasActive = !!activeDeployedWorkflowState
- const hasSelected = !!selectedDeployedWorkflowState
-
- const [view, setView] = useState(hasSelected ? 'selected' : 'active')
- const workflowToShow =
- view === 'current'
- ? currentWorkflowState
- : view === 'active'
- ? activeDeployedWorkflowState
- : selectedDeployedWorkflowState
- const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
-
- const previewKey = useMemo(() => {
- return `${view}-preview-${activeWorkflowId}`
- }, [view, activeWorkflowId])
-
- return (
-
-
-
-
Workflow Preview
-
- {/* Show Current only when no explicit version is selected */}
- {hasCurrent && !hasSelected && (
- setView('current')}
- >
- Current
-
- )}
- {/* Always show Active Deployed */}
- {hasActive && (
- setView('active')}
- >
- Active Deployed
-
- )}
- {/* If a specific version is selected, show its label */}
- {hasSelected && (
- setView('selected')}
- >
- {selectedVersionLabel || 'Selected Version'}
-
- )}
-
-
-
-
-
-
-
- {/* Workflow preview with fixed height */}
-
-
-
-
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deployed-workflow-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deployed-workflow-modal.tsx
deleted file mode 100644
index ba0beba9c..000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deployed-workflow-modal.tsx
+++ /dev/null
@@ -1,170 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { Button } from '@/components/emcn'
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- AlertDialogTrigger,
-} from '@/components/ui/alert-dialog'
-import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
-import { createLogger } from '@/lib/logs/console/logger'
-import { DeployedWorkflowCard } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deployed-workflow-card'
-import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
-import { mergeSubblockState } from '@/stores/workflows/utils'
-import { useWorkflowStore } from '@/stores/workflows/workflow/store'
-import type { WorkflowState } from '@/stores/workflows/workflow/types'
-
-const logger = createLogger('DeployedWorkflowModal')
-
-interface DeployedWorkflowModalProps {
- isOpen: boolean
- onClose: () => void
- needsRedeployment: boolean
- activeDeployedState?: WorkflowState
- selectedDeployedState?: WorkflowState
- selectedVersion?: number
- onActivateVersion?: () => void
- isActivating?: boolean
- selectedVersionLabel?: string
- workflowId: string
- isSelectedVersionActive?: boolean
- onLoadDeploymentComplete?: () => void
-}
-
-export function DeployedWorkflowModal({
- isOpen,
- onClose,
- needsRedeployment,
- activeDeployedState,
- selectedDeployedState,
- selectedVersion,
- onActivateVersion,
- isActivating,
- selectedVersionLabel,
- workflowId,
- isSelectedVersionActive,
- onLoadDeploymentComplete,
-}: DeployedWorkflowModalProps) {
- const [showRevertDialog, setShowRevertDialog] = useState(false)
- const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
-
- // Get current workflow state to compare with deployed state
- const currentWorkflowState = useWorkflowStore((state) => ({
- blocks: activeWorkflowId ? mergeSubblockState(state.blocks, activeWorkflowId) : state.blocks,
- edges: state.edges,
- loops: state.loops,
- parallels: state.parallels,
- }))
-
- const handleRevert = async () => {
- if (!activeWorkflowId) {
- logger.error('Cannot revert: no active workflow ID')
- return
- }
-
- try {
- const versionToRevert = selectedVersion !== undefined ? selectedVersion : 'active'
- const response = await fetch(
- `/api/workflows/${workflowId}/deployments/${versionToRevert}/revert`,
- {
- method: 'POST',
- }
- )
-
- if (!response.ok) {
- throw new Error('Failed to revert to version')
- }
-
- setShowRevertDialog(false)
- onClose()
- onLoadDeploymentComplete?.()
- } catch (error) {
- logger.error('Failed to revert workflow:', error)
- }
- }
-
- return (
-
-
-
-
- Deployed Workflow
-
-
-
-
-
-
- {onActivateVersion &&
- (isSelectedVersionActive ? (
-
-
-
-
-
- Active
-
- ) : (
-
- onActivateVersion?.()}
- >
- {isActivating ? 'Activating…' : 'Activate'}
-
-
- ))}
-
-
-
- {(needsRedeployment || selectedVersion !== undefined) && (
-
-
- Load Deployment
-
-
-
- Load this Deployment?
-
- This will replace your current workflow with the deployed version. Your
- current changes will be lost.
-
-
-
- Cancel
-
- Load Deployment
-
-
-
-
- )}
-
- Close
-
-
-
-
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deployment-info.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deployment-info.tsx
deleted file mode 100644
index a5daf5d2c..000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deployment-info.tsx
+++ /dev/null
@@ -1,203 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { Loader2 } from 'lucide-react'
-import {
- Button,
- Modal,
- ModalContent,
- ModalDescription,
- ModalFooter,
- ModalHeader,
- ModalTitle,
-} from '@/components/emcn'
-import { Skeleton } from '@/components/ui'
-import { ApiEndpoint } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api-endpoint'
-import { DeployStatus } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-status'
-import { DeployedWorkflowModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deployed-workflow-modal'
-import { ExampleCommand } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/example-command'
-import type { WorkflowState } from '@/stores/workflows/workflow/types'
-
-interface WorkflowDeploymentInfo {
- isDeployed: boolean
- deployedAt?: string
- apiKey: string
- endpoint: string
- exampleCommand: string
- needsRedeployment: boolean
-}
-
-interface DeploymentInfoProps {
- isLoading: boolean
- deploymentInfo: WorkflowDeploymentInfo | null
- onRedeploy: () => void
- onUndeploy: () => void
- isSubmitting: boolean
- isUndeploying: boolean
- workflowId: string | null
- deployedState: WorkflowState
- isLoadingDeployedState: boolean
- getInputFormatExample?: (includeStreaming?: boolean) => string
- selectedStreamingOutputs: string[]
- onSelectedStreamingOutputsChange: (outputs: string[]) => void
- onLoadDeploymentComplete: () => void
-}
-
-export function DeploymentInfo({
- isLoading,
- deploymentInfo,
- onRedeploy,
- onUndeploy,
- isSubmitting,
- isUndeploying,
- workflowId,
- deployedState,
- isLoadingDeployedState,
- getInputFormatExample,
- selectedStreamingOutputs,
- onSelectedStreamingOutputsChange,
- onLoadDeploymentComplete,
-}: DeploymentInfoProps) {
- const [isViewingDeployed, setIsViewingDeployed] = useState(false)
- const [showUndeployModal, setShowUndeployModal] = useState(false)
-
- const handleViewDeployed = async () => {
- if (!workflowId) {
- return
- }
-
- // If deployedState is already loaded, use it directly
- if (deployedState) {
- setIsViewingDeployed(true)
- return
- }
- }
-
- if (isLoading || !deploymentInfo) {
- return (
-
- {/* API Endpoint skeleton */}
-
-
-
-
-
- {/* API Key skeleton */}
-
-
-
-
-
- {/* Example Command skeleton */}
-
-
-
-
-
- {/* Deploy Status and buttons skeleton */}
-
-
- )
- }
-
- return (
- <>
-
-
-
-
-
-
-
-
- View Deployment
-
- {deploymentInfo.needsRedeployment && (
-
- {isSubmitting ? : null}
- {isSubmitting ? 'Redeploying...' : 'Redeploy'}
-
- )}
- setShowUndeployModal(true)}
- >
- {isUndeploying ? : null}
- {isUndeploying ? 'Undeploying...' : 'Undeploy'}
-
-
-
-
-
- {deployedState && workflowId && (
- setIsViewingDeployed(false)}
- needsRedeployment={deploymentInfo.needsRedeployment}
- activeDeployedState={deployedState}
- workflowId={workflowId}
- onLoadDeploymentComplete={onLoadDeploymentComplete}
- />
- )}
-
- {/* Undeploy Confirmation Modal */}
-
-
-
- Undeploy API
-
- Are you sure you want to undeploy this workflow?{' '}
-
- This will remove the API endpoint and make it unavailable to external users.{' '}
-
-
-
-
- setShowUndeployModal(false)}
- disabled={isUndeploying}
- >
- Cancel
-
- {
- onUndeploy()
- setShowUndeployModal(false)
- }}
- disabled={isUndeploying}
- >
- {isUndeploying ? 'Undeploying...' : 'Undeploy'}
-
-
-
-
- >
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/example-command.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/example-command.tsx
deleted file mode 100644
index c35910e23..000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/example-command.tsx
+++ /dev/null
@@ -1,231 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { ChevronDown } from 'lucide-react'
-import { Button, Label } from '@/components/emcn'
-import { Button as UIButton } from '@/components/ui/button'
-import { CopyButton } from '@/components/ui/copy-button'
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu'
-import { getEnv, isTruthy } from '@/lib/core/config/env'
-import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select'
-
-interface ExampleCommandProps {
- command: string
- apiKey: string
- endpoint: string
- showLabel?: boolean
- getInputFormatExample?: (includeStreaming?: boolean) => string
- workflowId: string | null
- selectedStreamingOutputs: string[]
- onSelectedStreamingOutputsChange: (outputs: string[]) => void
-}
-
-type ExampleMode = 'sync' | 'async' | 'stream'
-type ExampleType = 'execute' | 'status' | 'rate-limits'
-
-export function ExampleCommand({
- command,
- apiKey,
- endpoint,
- showLabel = true,
- getInputFormatExample,
- workflowId,
- selectedStreamingOutputs,
- onSelectedStreamingOutputsChange,
-}: ExampleCommandProps) {
- const [mode, setMode] = useState('sync')
- const [exampleType, setExampleType] = useState('execute')
- const isAsyncEnabled = isTruthy(getEnv('NEXT_PUBLIC_TRIGGER_DEV_ENABLED'))
-
- const formatCurlCommand = (command: string, apiKey: string) => {
- if (!command.includes('curl')) return command
-
- const sanitizedCommand = command.replace(apiKey, '$SIM_API_KEY')
-
- return sanitizedCommand
- .replace(' -H ', '\n -H ')
- .replace(' -d ', '\n -d ')
- .replace(' http', '\n http')
- }
-
- const getActualCommand = () => {
- const displayCommand = getDisplayCommand()
- return displayCommand
- .replace(/\\\n\s*/g, ' ') // Remove backslash + newline + whitespace
- .replace(/\n\s*/g, ' ') // Remove any remaining newlines + whitespace
- .replace(/\s+/g, ' ') // Normalize multiple spaces to single space
- .trim()
- }
-
- const getDisplayCommand = () => {
- const baseEndpoint = endpoint.replace(apiKey, '$SIM_API_KEY')
- const inputExample = getInputFormatExample
- ? getInputFormatExample(false)
- : ' -d \'{"input": "your data here"}\''
-
- const addStreamingParams = (dashD: string) => {
- const match = dashD.match(/-d\s*'([\s\S]*)'/)
- if (!match) {
- const payload: Record = { stream: true }
- if (selectedStreamingOutputs && selectedStreamingOutputs.length > 0) {
- payload.selectedOutputs = selectedStreamingOutputs
- }
- return ` -d '${JSON.stringify(payload)}'`
- }
- try {
- const payload = JSON.parse(match[1]) as Record
- payload.stream = true
- if (selectedStreamingOutputs && selectedStreamingOutputs.length > 0) {
- payload.selectedOutputs = selectedStreamingOutputs
- }
- return ` -d '${JSON.stringify(payload)}'`
- } catch {
- return dashD
- }
- }
-
- switch (mode) {
- case 'sync':
- if (getInputFormatExample) {
- const syncInputExample = getInputFormatExample(false)
- return `curl -X POST \\\n -H "X-API-Key: $SIM_API_KEY" \\\n -H "Content-Type: application/json"${syncInputExample} \\\n ${baseEndpoint}`
- }
- return formatCurlCommand(command, apiKey)
-
- case 'stream': {
- const streamDashD = addStreamingParams(inputExample)
- return `curl -X POST \\\n -H "X-API-Key: $SIM_API_KEY" \\\n -H "Content-Type: application/json"${streamDashD} \\\n ${baseEndpoint}`
- }
-
- case 'async':
- switch (exampleType) {
- case 'execute':
- return `curl -X POST \\\n -H "X-API-Key: $SIM_API_KEY" \\\n -H "Content-Type: application/json" \\\n -H "X-Execution-Mode: async"${inputExample} \\\n ${baseEndpoint}`
-
- case 'status': {
- const baseUrl = baseEndpoint.split('/api/workflows/')[0]
- return `curl -H "X-API-Key: $SIM_API_KEY" \\\n ${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION`
- }
-
- case 'rate-limits': {
- const baseUrlForRateLimit = baseEndpoint.split('/api/workflows/')[0]
- return `curl -H "X-API-Key: $SIM_API_KEY" \\\n ${baseUrlForRateLimit}/api/users/me/usage-limits`
- }
-
- default:
- return formatCurlCommand(command, apiKey)
- }
-
- default:
- return formatCurlCommand(command, apiKey)
- }
- }
-
- const getExampleTitle = () => {
- switch (exampleType) {
- case 'execute':
- return 'Async Execution'
- case 'status':
- return 'Check Job Status'
- case 'rate-limits':
- return 'Rate Limits & Usage'
- default:
- return 'Async Execution'
- }
- }
-
- return (
-
- {/* Example Command */}
-
-
- {showLabel &&
Example }
-
- setMode('sync')}
- className='h-6 min-w-[50px] px-2 py-1 text-xs'
- >
- Sync
-
- setMode('stream')}
- className='h-6 min-w-[50px] px-2 py-1 text-xs'
- >
- Stream
-
- {isAsyncEnabled && (
- <>
- setMode('async')}
- className='h-6 min-w-[50px] px-2 py-1 text-xs'
- >
- Async
-
-
-
-
- {getExampleTitle()}
-
-
-
-
- setExampleType('execute')}
- >
- Async Execution
-
- setExampleType('status')}
- >
- Check Job Status
-
- setExampleType('rate-limits')}
- >
- Rate Limits & Usage
-
-
-
- >
- )}
-
-
-
- {/* Output selector for Stream mode */}
- {mode === 'stream' && (
-
-
Select outputs to stream
-
-
- )}
-
-
-
{getDisplayCommand()}
-
-
-
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/index.ts
new file mode 100644
index 000000000..a9946f7d5
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/index.ts
@@ -0,0 +1 @@
+export { Versions } from './versions'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx
new file mode 100644
index 000000000..28a98dd4c
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx
@@ -0,0 +1,333 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import clsx from 'clsx'
+import { MoreVertical, Pencil, RotateCcw, SendToBack } from 'lucide-react'
+import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
+import { Skeleton } from '@/components/ui'
+import { createLogger } from '@/lib/logs/console/logger'
+import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
+
+const logger = createLogger('Versions')
+
+/** Shared styling constants aligned with terminal component */
+const HEADER_TEXT_CLASS = 'font-medium text-[var(--text-tertiary)] text-[12px]'
+const ROW_TEXT_CLASS = 'font-medium text-[#D2D2D2] text-[12px]'
+const COLUMN_BASE_CLASS = 'flex-shrink-0'
+
+/** Column width configuration */
+const COLUMN_WIDTHS = {
+ VERSION: 'w-[180px]',
+ DEPLOYED_BY: 'w-[140px]',
+ TIMESTAMP: 'flex-1',
+ ACTIONS: 'w-[32px]',
+} as const
+
+interface VersionsProps {
+ workflowId: string | null
+ versions: WorkflowDeploymentVersionResponse[]
+ versionsLoading: boolean
+ selectedVersion: number | null
+ onSelectVersion: (version: number | null) => void
+ onPromoteToLive: (version: number) => void
+ onLoadDeployment: (version: number) => void
+ fetchVersions: () => Promise
+}
+
+/**
+ * Formats a timestamp into a readable string.
+ * @param value - The date string or Date object to format
+ * @returns Formatted string like "8:36 PM PT on Oct 11, 2025"
+ */
+const formatDate = (value: string | Date): string => {
+ const date = value instanceof Date ? value : new Date(value)
+ if (Number.isNaN(date.getTime())) {
+ return '-'
+ }
+
+ const timePart = date.toLocaleTimeString('en-US', {
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+ timeZoneName: 'short',
+ })
+
+ const datePart = date.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ })
+
+ return `${timePart} on ${datePart}`
+}
+
+/**
+ * Displays a list of workflow deployment versions with actions
+ * for viewing, promoting to live, renaming, and loading deployments.
+ */
+export function Versions({
+ workflowId,
+ versions,
+ versionsLoading,
+ selectedVersion,
+ onSelectVersion,
+ onPromoteToLive,
+ onLoadDeployment,
+ fetchVersions,
+}: VersionsProps) {
+ const [editingVersion, setEditingVersion] = useState(null)
+ const [editValue, setEditValue] = useState('')
+ const [isRenaming, setIsRenaming] = useState(false)
+ const [openDropdown, setOpenDropdown] = useState(null)
+ const inputRef = useRef(null)
+
+ useEffect(() => {
+ if (editingVersion !== null && inputRef.current) {
+ inputRef.current.focus()
+ inputRef.current.select()
+ }
+ }, [editingVersion])
+
+ const handleStartRename = (version: number, currentName: string | null | undefined) => {
+ setOpenDropdown(null)
+ setEditingVersion(version)
+ setEditValue(currentName || `v${version}`)
+ }
+
+ const handleSaveRename = async (version: number) => {
+ if (!workflowId || !editValue.trim()) {
+ setEditingVersion(null)
+ return
+ }
+
+ const currentVersion = versions.find((v) => v.version === version)
+ const currentName = currentVersion?.name || `v${version}`
+
+ if (editValue.trim() === currentName) {
+ setEditingVersion(null)
+ return
+ }
+
+ setIsRenaming(true)
+ try {
+ const res = await fetch(`/api/workflows/${workflowId}/deployments/${version}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: editValue.trim() }),
+ })
+
+ if (res.ok) {
+ await fetchVersions()
+ setEditingVersion(null)
+ } else {
+ logger.error('Failed to rename version')
+ }
+ } catch (error) {
+ logger.error('Error renaming version:', error)
+ } finally {
+ setIsRenaming(false)
+ }
+ }
+
+ const handleCancelRename = () => {
+ setEditingVersion(null)
+ setEditValue('')
+ }
+
+ const handleRowClick = (version: number) => {
+ if (editingVersion === version) return
+ onSelectVersion(selectedVersion === version ? null : version)
+ }
+
+ const handlePromote = (version: number) => {
+ setOpenDropdown(null)
+ onPromoteToLive(version)
+ }
+
+ const handleLoadDeployment = (version: number) => {
+ setOpenDropdown(null)
+ onLoadDeployment(version)
+ }
+
+ if (versionsLoading && versions.length === 0) {
+ return (
+
+
+
+ {[0, 1].map((i) => (
+
+ ))}
+
+
+ )
+ }
+
+ if (versions.length === 0) {
+ return (
+
+ No deployments yet
+
+ )
+ }
+
+ return (
+
+
+
+ Version
+
+
+ Deployed by
+
+
+ Timestamp
+
+
+
+
+
+ {versions.map((v) => {
+ const isSelected = selectedVersion === v.version
+
+ return (
+
handleRowClick(v.version)}
+ >
+
+
+
+ {editingVersion === v.version ? (
+
setEditValue(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ handleSaveRename(v.version)
+ } else if (e.key === 'Escape') {
+ e.preventDefault()
+ handleCancelRename()
+ }
+ }}
+ onClick={(e) => e.stopPropagation()}
+ onBlur={() => handleSaveRename(v.version)}
+ className={clsx(
+ 'w-full border-0 bg-transparent p-0 font-medium text-[12px] leading-5 outline-none',
+ 'text-[var(--text-primary)] focus:outline-none focus:ring-0'
+ )}
+ maxLength={100}
+ disabled={isRenaming}
+ autoComplete='off'
+ autoCorrect='off'
+ autoCapitalize='off'
+ spellCheck='false'
+ />
+ ) : (
+
+ {v.name || `v${v.version}`}
+ {v.isActive && (live) }
+ {isSelected && (
+ (selected)
+ )}
+
+ )}
+
+
+
+
+
+ {v.deployedBy || 'Unknown'}
+
+
+
+
+
+ {formatDate(v.createdAt)}
+
+
+
+
e.stopPropagation()}
+ >
+
setOpenDropdown(open ? v.version : null)}
+ >
+
+
+
+
+
+
+ handleStartRename(v.version, v.name)}>
+
+ Rename
+
+ {!v.isActive && (
+ handlePromote(v.version)}>
+
+ Promote to live
+
+ )}
+ handleLoadDeployment(v.version)}>
+
+ Load deployment
+
+
+
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx
new file mode 100644
index 000000000..bce4b6f27
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx
@@ -0,0 +1,312 @@
+'use client'
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { Button, Label } from '@/components/emcn'
+import {
+ Modal,
+ ModalBody,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+} from '@/components/emcn/components/modal/modal'
+import { Skeleton } from '@/components/ui'
+import { createLogger } from '@/lib/logs/console/logger'
+import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
+import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
+import type { WorkflowState } from '@/stores/workflows/workflow/types'
+import { Versions } from './components'
+
+const logger = createLogger('GeneralDeploy')
+
+interface GeneralDeployProps {
+ workflowId: string | null
+ deployedState: WorkflowState
+ isLoadingDeployedState: boolean
+ versions: WorkflowDeploymentVersionResponse[]
+ versionsLoading: boolean
+ onPromoteToLive: (version: number) => Promise
+ onLoadDeploymentComplete: () => void
+ fetchVersions: () => Promise
+}
+
+type PreviewMode = 'active' | 'selected'
+
+/**
+ * General deployment tab content displaying live workflow preview and version history.
+ */
+export function GeneralDeploy({
+ workflowId,
+ deployedState,
+ isLoadingDeployedState,
+ versions,
+ versionsLoading,
+ onPromoteToLive,
+ onLoadDeploymentComplete,
+ fetchVersions,
+}: GeneralDeployProps) {
+ const [selectedVersion, setSelectedVersion] = useState(null)
+ const [previewMode, setPreviewMode] = useState('active')
+ const [showLoadDialog, setShowLoadDialog] = useState(false)
+ const [showPromoteDialog, setShowPromoteDialog] = useState(false)
+ const [versionToLoad, setVersionToLoad] = useState(null)
+ const [versionToPromote, setVersionToPromote] = useState(null)
+
+ const versionCacheRef = useRef>(new Map())
+ const [, forceUpdate] = useState({})
+
+ const selectedVersionInfo = versions.find((v) => v.version === selectedVersion)
+ const versionToPromoteInfo = versions.find((v) => v.version === versionToPromote)
+ const versionToLoadInfo = versions.find((v) => v.version === versionToLoad)
+
+ const cachedSelectedState =
+ selectedVersion !== null ? versionCacheRef.current.get(selectedVersion) : null
+
+ const fetchSelectedVersionState = useCallback(
+ async (version: number) => {
+ if (!workflowId) return
+ if (versionCacheRef.current.has(version)) return
+
+ try {
+ const res = await fetch(`/api/workflows/${workflowId}/deployments/${version}`)
+ if (res.ok) {
+ const data = await res.json()
+ if (data.deployedState) {
+ versionCacheRef.current.set(version, data.deployedState)
+ forceUpdate({})
+ }
+ }
+ } catch (error) {
+ logger.error('Error fetching version state:', error)
+ }
+ },
+ [workflowId]
+ )
+
+ useEffect(() => {
+ if (selectedVersion !== null) {
+ fetchSelectedVersionState(selectedVersion)
+ setPreviewMode('selected')
+ } else {
+ setPreviewMode('active')
+ }
+ }, [selectedVersion, fetchSelectedVersionState])
+
+ const handleSelectVersion = useCallback((version: number | null) => {
+ setSelectedVersion(version)
+ }, [])
+
+ const handleLoadDeployment = useCallback((version: number) => {
+ setVersionToLoad(version)
+ setShowLoadDialog(true)
+ }, [])
+
+ const handlePromoteToLive = useCallback((version: number) => {
+ setVersionToPromote(version)
+ setShowPromoteDialog(true)
+ }, [])
+
+ const confirmLoadDeployment = async () => {
+ if (!workflowId || versionToLoad === null) return
+
+ // Close modal immediately for snappy UX
+ setShowLoadDialog(false)
+ const version = versionToLoad
+ setVersionToLoad(null)
+
+ try {
+ const response = await fetch(`/api/workflows/${workflowId}/deployments/${version}/revert`, {
+ method: 'POST',
+ })
+
+ if (!response.ok) {
+ throw new Error('Failed to load deployment')
+ }
+
+ onLoadDeploymentComplete()
+ } catch (error) {
+ logger.error('Failed to load deployment:', error)
+ }
+ }
+
+ const confirmPromoteToLive = async () => {
+ if (versionToPromote === null) return
+
+ // Close modal immediately for snappy UX
+ setShowPromoteDialog(false)
+ const version = versionToPromote
+ setVersionToPromote(null)
+
+ try {
+ await onPromoteToLive(version)
+ } catch (error) {
+ logger.error('Failed to promote version:', error)
+ }
+ }
+
+ const workflowToShow = useMemo(() => {
+ if (previewMode === 'selected' && cachedSelectedState) {
+ return cachedSelectedState
+ }
+ return deployedState
+ }, [previewMode, cachedSelectedState, deployedState])
+
+ const showToggle = selectedVersion !== null && deployedState
+
+ // Only show skeleton on initial load when we have no deployed data
+ const hasDeployedData = deployedState && Object.keys(deployedState.blocks || {}).length > 0
+ const showLoadingSkeleton = isLoadingDeployedState && !hasDeployedData
+
+ if (showLoadingSkeleton) {
+ return (
+
+ )
+ }
+
+ return (
+ <>
+
+
+
+
+ {previewMode === 'selected' && selectedVersionInfo
+ ? selectedVersionInfo.name || `v${selectedVersion}`
+ : 'Live Workflow'}
+
+
+ setPreviewMode('active')}
+ className='rounded-r-none px-[8px] py-[4px] text-[12px]'
+ >
+ Live
+
+ setPreviewMode('selected')}
+ className='truncate rounded-l-none px-[8px] py-[4px] text-[12px]'
+ >
+ {selectedVersionInfo?.name || `v${selectedVersion}`}
+
+
+
+
+
{
+ if (e.ctrlKey || e.metaKey) return
+ e.stopPropagation()
+ }}
+ >
+ {workflowToShow ? (
+
+ ) : (
+
+ Deploy your workflow to see a preview
+
+ )}
+
+
+
+
+
+ Versions
+
+
+
+
+
+
+
+ Load Deployment
+
+
+ Are you sure you want to load{' '}
+
+ {versionToLoadInfo?.name || `v${versionToLoad}`}
+
+ ?{' '}
+
+ This will replace your current workflow with the deployed version.
+
+
+
+
+ setShowLoadDialog(false)}>
+ Cancel
+
+
+ Load deployment
+
+
+
+
+
+
+
+ Promote to live
+
+
+ Are you sure you want to promote{' '}
+
+ {versionToPromoteInfo?.name || `v${versionToPromote}`}
+ {' '}
+ to live?{' '}
+
+ This version will become the active deployment and serve all API requests.
+
+
+
+
+ setShowPromoteDialog(false)}>
+ Cancel
+
+
+ Promote to live
+
+
+
+
+ >
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/identifier-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/identifier-input.tsx
deleted file mode 100644
index d6989bb87..000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/identifier-input.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import { useEffect } from 'react'
-import { Input, Label } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
-import { getEmailDomain } from '@/lib/core/utils/urls'
-import { useIdentifierValidation } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-identifier-validation'
-
-interface IdentifierInputProps {
- value: string
- onChange: (value: string) => void
- originalIdentifier?: string
- disabled?: boolean
- onValidationChange?: (isValid: boolean) => void
- isEditingExisting?: boolean
-}
-
-const getDomainPrefix = (() => {
- const prefix = `${getEmailDomain()}/chat/`
- return () => prefix
-})()
-
-export function IdentifierInput({
- value,
- onChange,
- originalIdentifier,
- disabled = false,
- onValidationChange,
- isEditingExisting = false,
-}: IdentifierInputProps) {
- const { isChecking, error, isValid } = useIdentifierValidation(
- value,
- originalIdentifier,
- isEditingExisting
- )
-
- // Notify parent of validation changes
- useEffect(() => {
- onValidationChange?.(isValid)
- }, [isValid, onValidationChange])
-
- const handleChange = (newValue: string) => {
- const lowercaseValue = newValue.toLowerCase()
- onChange(lowercaseValue)
- }
-
- return (
-
-
- Identifier
-
-
-
- {getDomainPrefix()}
-
-
-
handleChange(e.target.value)}
- required
- disabled={disabled}
- className={cn(
- 'rounded-l-none border-l-0',
- isChecking && 'pr-8',
- error && 'border-destructive'
- )}
- />
- {isChecking && (
-
- )}
-
-
- {error &&
{error}
}
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/success-view.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/success-view.tsx
deleted file mode 100644
index 1f8404097..000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/success-view.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import { Label } from '@/components/emcn'
-import { getBaseDomain, getEmailDomain } from '@/lib/core/utils/urls'
-
-interface ExistingChat {
- id: string
- identifier: string
- title: string
- description: string
- authType: 'public' | 'password' | 'email'
- allowedEmails: string[]
- outputConfigs: Array<{ blockId: string; path: string }>
- customizations?: {
- welcomeMessage?: string
- }
- isActive: boolean
-}
-
-interface SuccessViewProps {
- deployedUrl: string
- existingChat: ExistingChat | null
- onDelete?: () => void
- onUpdate?: () => void
-}
-
-export function SuccessView({ deployedUrl, existingChat, onDelete, onUpdate }: SuccessViewProps) {
- const url = new URL(deployedUrl)
- const hostname = url.hostname
- const isDevelopmentUrl = hostname.includes('localhost')
-
- // Extract identifier from path-based URL format (e.g., sim.ai/chat/identifier)
- const pathParts = url.pathname.split('/')
- const identifierPart = pathParts[2] || '' // /chat/identifier
-
- let domainPrefix
- if (isDevelopmentUrl) {
- const baseDomain = getBaseDomain()
- const baseHost = baseDomain.split(':')[0]
- const port = url.port || (baseDomain.includes(':') ? baseDomain.split(':')[1] : '3000')
- domainPrefix = `${baseHost}:${port}/chat/`
- } else {
- domainPrefix = `${getEmailDomain()}/chat/`
- }
-
- return (
-
-
-
- Chat {existingChat ? 'Update' : 'Deployment'} Successful
-
-
-
- Your chat is now live at{' '}
-
- this URL
-
-
-
-
- {/* Hidden triggers for modal footer buttons */}
-
-
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/template-deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/template-deploy.tsx
deleted file mode 100644
index 893f2e56e..000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/template-deploy.tsx
+++ /dev/null
@@ -1,489 +0,0 @@
-'use client'
-
-import { useEffect, useState } from 'react'
-import { zodResolver } from '@hookform/resolvers/zod'
-import { CheckCircle2, Loader2, Plus } from 'lucide-react'
-import { useForm } from 'react-hook-form'
-import { z } from 'zod'
-import { Badge, Button, Input, Textarea, Trash } from '@/components/emcn'
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui'
-import { TagInput } from '@/components/ui/tag-input'
-import { useSession } from '@/lib/auth/auth-client'
-import { createLogger } from '@/lib/logs/console/logger'
-import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
-import {
- useCreateTemplate,
- useDeleteTemplate,
- useTemplateByWorkflow,
- useUpdateTemplate,
-} from '@/hooks/queries/templates'
-import type { WorkflowState } from '@/stores/workflows/workflow/types'
-
-const logger = createLogger('TemplateDeploy')
-
-const templateSchema = z.object({
- name: z.string().min(1, 'Name is required').max(100, 'Max 100 characters'),
- tagline: z.string().max(500, 'Max 500 characters').optional(),
- about: z.string().optional(), // Markdown long description
- creatorId: z.string().optional(), // Creator profile ID
- tags: z.array(z.string()).max(10, 'Maximum 10 tags allowed').optional().default([]),
-})
-
-type TemplateFormData = z.infer
-
-interface CreatorOption {
- id: string
- name: string
- referenceType: 'user' | 'organization'
- referenceId: string
-}
-
-interface TemplateDeployProps {
- workflowId: string
- onDeploymentComplete?: () => void
-}
-
-export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDeployProps) {
- const { data: session } = useSession()
- const [showDeleteDialog, setShowDeleteDialog] = useState(false)
- const [creatorOptions, setCreatorOptions] = useState([])
- const [loadingCreators, setLoadingCreators] = useState(false)
- const [showPreviewDialog, setShowPreviewDialog] = useState(false)
-
- const { data: existingTemplate, isLoading: isLoadingTemplate } = useTemplateByWorkflow(workflowId)
- const createMutation = useCreateTemplate()
- const updateMutation = useUpdateTemplate()
- const deleteMutation = useDeleteTemplate()
-
- const form = useForm({
- resolver: zodResolver(templateSchema),
- defaultValues: {
- name: '',
- tagline: '',
- about: '',
- creatorId: undefined,
- tags: [],
- },
- })
-
- const fetchCreatorOptions = async () => {
- if (!session?.user?.id) return
-
- setLoadingCreators(true)
- try {
- const response = await fetch('/api/creators')
- if (response.ok) {
- const data = await response.json()
- const profiles = (data.profiles || []).map((profile: any) => ({
- id: profile.id,
- name: profile.name,
- referenceType: profile.referenceType,
- referenceId: profile.referenceId,
- }))
- setCreatorOptions(profiles)
- return profiles
- }
- } catch (error) {
- logger.error('Error fetching creator profiles:', error)
- } finally {
- setLoadingCreators(false)
- }
- return []
- }
-
- useEffect(() => {
- fetchCreatorOptions()
- }, [session?.user?.id])
-
- useEffect(() => {
- const currentCreatorId = form.getValues('creatorId')
- if (creatorOptions.length === 1 && !currentCreatorId) {
- form.setValue('creatorId', creatorOptions[0].id)
- logger.info('Auto-selected single creator profile:', creatorOptions[0].name)
- }
- }, [creatorOptions, form])
-
- useEffect(() => {
- const handleCreatorProfileSaved = async () => {
- logger.info('Creator profile saved, refreshing profiles...')
-
- await fetchCreatorOptions()
-
- window.dispatchEvent(new CustomEvent('close-settings'))
- 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) {
- const tagline = existingTemplate.details?.tagline || ''
- const about = existingTemplate.details?.about || ''
-
- form.reset({
- name: existingTemplate.name,
- tagline: tagline,
- about: about,
- creatorId: existingTemplate.creatorId || undefined,
- tags: existingTemplate.tags || [],
- })
- }
- }, [existingTemplate, form])
-
- const onSubmit = async (data: TemplateFormData) => {
- if (!session?.user) {
- logger.error('User not authenticated')
- return
- }
-
- try {
- const templateData = {
- name: data.name,
- details: {
- tagline: data.tagline || '',
- about: data.about || '',
- },
- creatorId: data.creatorId || undefined,
- tags: data.tags || [],
- }
-
- if (existingTemplate) {
- await updateMutation.mutateAsync({
- id: existingTemplate.id,
- data: {
- ...templateData,
- updateState: true,
- },
- })
- } else {
- await createMutation.mutateAsync({ ...templateData, workflowId })
- }
-
- logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully`)
- onDeploymentComplete?.()
- } catch (error) {
- logger.error('Failed to save template:', error)
- }
- }
-
- const handleDelete = async () => {
- if (!existingTemplate) return
-
- try {
- await deleteMutation.mutateAsync(existingTemplate.id)
- setShowDeleteDialog(false)
- form.reset({
- name: '',
- tagline: '',
- about: '',
- creatorId: undefined,
- tags: [],
- })
- } catch (error) {
- logger.error('Error deleting template:', error)
- }
- }
-
- if (isLoadingTemplate) {
- return (
-
-
-
- )
- }
-
- return (
-
- {existingTemplate && (
-
-
-
-
-
- Template Connected
-
- {existingTemplate.status === 'pending' && (
-
- Under Review
-
- )}
- {existingTemplate.status === 'approved' && existingTemplate.views > 0 && (
-
- • {existingTemplate.views} views
- {existingTemplate.stars > 0 && ` • ${existingTemplate.stars} stars`}
-
- )}
-
-
-
setShowDeleteDialog(true)}
- className='h-[32px] px-[8px] text-[var(--text-muted)] hover:text-red-600 dark:hover:text-red-400'
- >
-
-
-
- )}
-
-
-
- (
-
- Template Name
-
-
-
-
-
- )}
- />
-
- (
-
- Tagline
-
-
-
-
-
- )}
- />
-
- (
-
- About (Optional)
-
-
-
-
-
- )}
- />
-
- (
-
- Creator Profile
- {creatorOptions.length === 0 && !loadingCreators ? (
-
-
{
- try {
- const event = new CustomEvent('open-settings', {
- detail: { tab: 'creator-profile' },
- })
- window.dispatchEvent(event)
- logger.info('Opened Settings modal at creator-profile section')
- } catch (error) {
- logger.error('Failed to open Settings modal for creator profile', {
- error,
- })
- }
- }}
- className='gap-[8px]'
- >
-
- Create a Creator Profile
-
-
- ) : (
-
-
-
-
-
-
-
- {creatorOptions.map((option) => (
-
- {option.name}
-
- ))}
-
-
- )}
-
-
- )}
- />
-
- (
-
- Tags
-
-
-
-
- Add up to 10 tags to help users discover your template
-
-
-
- )}
- />
-
-
- {existingTemplate && (
- setShowPreviewDialog(true)}
- disabled={!existingTemplate?.state}
- >
- View Current
-
- )}
-
- {createMutation.isPending || updateMutation.isPending ? (
- <>
-
- {existingTemplate ? 'Updating...' : 'Publishing...'}
- >
- ) : existingTemplate ? (
- 'Update Template'
- ) : (
- 'Publish Template'
- )}
-
-
-
-
-
- {showDeleteDialog && (
-
-
-
- Delete Template?
-
-
- This will permanently delete your template. This action cannot be undone.
-
-
- setShowDeleteDialog(false)}>
- Cancel
-
-
- {deleteMutation.isPending ? 'Deleting...' : 'Delete'}
-
-
-
-
- )}
-
- {/* Template State Preview Dialog */}
-
-
-
- Published Template Preview
-
- {showPreviewDialog && (
-
- {(() => {
- if (!existingTemplate?.state || !existingTemplate.state.blocks) {
- return (
-
-
-
No template state available yet.
-
- Click "Update Template" to capture the current workflow state.
-
-
-
- )
- }
-
- 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 (
-
-
-
- )
- })()}
-
- )}
-
-
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/template/template.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/template/template.tsx
new file mode 100644
index 000000000..4d24f6636
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/template/template.tsx
@@ -0,0 +1,451 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { Loader2, Plus } from 'lucide-react'
+import { Button, Combobox, Input, Label, Textarea } from '@/components/emcn'
+import {
+ Modal,
+ ModalBody,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+} from '@/components/emcn/components/modal/modal'
+import { Skeleton, TagInput } from '@/components/ui'
+import { useSession } from '@/lib/auth/auth-client'
+import { createLogger } from '@/lib/logs/console/logger'
+import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
+import {
+ useCreateTemplate,
+ useDeleteTemplate,
+ useTemplateByWorkflow,
+ useUpdateTemplate,
+} from '@/hooks/queries/templates'
+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 CreatorOption {
+ id: string
+ name: string
+ referenceType: 'user' | 'organization'
+ referenceId: string
+}
+
+interface TemplateStatus {
+ status: 'pending' | 'approved' | 'rejected' | null
+ views?: number
+ stars?: number
+}
+
+interface TemplateDeployProps {
+ workflowId: string
+ onDeploymentComplete?: () => void
+ onValidationChange?: (isValid: boolean) => void
+ onSubmittingChange?: (isSubmitting: boolean) => void
+ onExistingTemplateChange?: (exists: boolean) => void
+ onTemplateStatusChange?: (status: TemplateStatus | null) => void
+}
+
+export function TemplateDeploy({
+ workflowId,
+ onDeploymentComplete,
+ onValidationChange,
+ onSubmittingChange,
+ onExistingTemplateChange,
+ onTemplateStatusChange,
+}: TemplateDeployProps) {
+ const { data: session } = useSession()
+ const [showDeleteDialog, setShowDeleteDialog] = useState(false)
+ const [creatorOptions, setCreatorOptions] = useState([])
+ const [loadingCreators, setLoadingCreators] = useState(false)
+
+ const [formData, setFormData] = useState(initialFormData)
+
+ const { data: existingTemplate, isLoading: isLoadingTemplate } = useTemplateByWorkflow(workflowId)
+ 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
+
+ const updateField = (field: K, value: TemplateFormData[K]) => {
+ setFormData((prev) => ({ ...prev, [field]: value }))
+ }
+
+ useEffect(() => {
+ onValidationChange?.(isFormValid)
+ }, [isFormValid, onValidationChange])
+
+ useEffect(() => {
+ onSubmittingChange?.(isSubmitting)
+ }, [isSubmitting, onSubmittingChange])
+
+ useEffect(() => {
+ onExistingTemplateChange?.(!!existingTemplate)
+ }, [existingTemplate, onExistingTemplateChange])
+
+ useEffect(() => {
+ if (existingTemplate) {
+ onTemplateStatusChange?.({
+ status: existingTemplate.status as 'pending' | 'approved' | 'rejected',
+ views: existingTemplate.views,
+ stars: existingTemplate.stars,
+ })
+ } else {
+ onTemplateStatusChange?.(null)
+ }
+ }, [existingTemplate, onTemplateStatusChange])
+
+ const fetchCreatorOptions = async () => {
+ if (!session?.user?.id) return
+
+ setLoadingCreators(true)
+ try {
+ const response = await fetch('/api/creator-profiles')
+ if (response.ok) {
+ const data = await response.json()
+ const profiles = (data.profiles || []).map((profile: any) => ({
+ id: profile.id,
+ name: profile.name,
+ referenceType: profile.referenceType,
+ referenceId: profile.referenceId,
+ }))
+ setCreatorOptions(profiles)
+ return profiles
+ }
+ } catch (error) {
+ logger.error('Error fetching creator profiles:', error)
+ } finally {
+ setLoadingCreators(false)
+ }
+ return []
+ }
+
+ useEffect(() => {
+ fetchCreatorOptions()
+ }, [session?.user?.id])
+
+ useEffect(() => {
+ if (creatorOptions.length === 1 && !formData.creatorId) {
+ updateField('creatorId', creatorOptions[0].id)
+ logger.info('Auto-selected single creator profile:', creatorOptions[0].name)
+ }
+ }, [creatorOptions, formData.creatorId])
+
+ useEffect(() => {
+ const handleCreatorProfileSaved = async () => {
+ logger.info('Creator profile saved, refreshing profiles...')
+
+ await fetchCreatorOptions()
+
+ window.dispatchEvent(new CustomEvent('close-settings'))
+ 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 || undefined,
+ tags: formData.tags,
+ }
+
+ if (existingTemplate) {
+ await updateMutation.mutateAsync({
+ id: existingTemplate.id,
+ data: {
+ ...templateData,
+ updateState: true,
+ },
+ })
+ } else {
+ await createMutation.mutateAsync({ ...templateData, workflowId })
+ }
+
+ logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully`)
+ 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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+ {existingTemplate?.state && (
+
+
+ Live Template
+
+
{
+ if (e.ctrlKey || e.metaKey) return
+ e.stopPropagation()
+ }}
+ >
+
+
+
+ )}
+
+
+
+ Name
+
+ updateField('name', e.target.value)}
+ disabled={isSubmitting}
+ />
+
+
+
+
+ Tagline
+
+ updateField('tagline', e.target.value)}
+ disabled={isSubmitting}
+ />
+
+
+
+
+ Description
+
+ updateField('about', e.target.value)}
+ disabled={isSubmitting}
+ />
+
+
+
+
+ Creator
+
+ {creatorOptions.length === 0 && !loadingCreators ? (
+
{
+ try {
+ const event = new CustomEvent('open-settings', {
+ detail: { tab: 'creator-profile' },
+ })
+ window.dispatchEvent(event)
+ logger.info('Opened Settings modal at creator-profile section')
+ } catch (error) {
+ logger.error('Failed to open Settings modal for creator profile', {
+ error,
+ })
+ }
+ }}
+ className='gap-[8px]'
+ >
+
+ Create a Creator Profile
+
+ ) : (
+
({
+ label: option.name,
+ value: option.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}
+ />
+ )}
+
+
+
+
+ Tags
+
+ updateField('tags', tags)}
+ placeholder='Dev, Agents, Research, etc.'
+ maxTags={10}
+ disabled={isSubmitting}
+ />
+
+
+ setShowDeleteDialog(true)}
+ style={{ display: 'none' }}
+ />
+
+
+
+
+ Delete Template
+
+
+ Are you sure you want to delete this template?{' '}
+ This action cannot be undone.
+
+
+
+ setShowDeleteDialog(false)}>
+ Cancel
+
+
+ {deleteMutation.isPending ? (
+ <>
+
+ Deleting...
+ >
+ ) : (
+ 'Delete'
+ )}
+
+
+
+
+
+ )
+}
+
+interface TemplatePreviewContentProps {
+ existingTemplate:
+ | {
+ id: string
+ state?: Partial
+ }
+ | 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 (
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx
index 36164f6b6..446fc1da7 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx
@@ -1,33 +1,38 @@
'use client'
-import { useEffect, useRef, useState } from 'react'
-import { Loader2, MoreVertical, X } from 'lucide-react'
+import { useCallback, useEffect, useState } from 'react'
+import clsx from 'clsx'
+import { Button } from '@/components/emcn'
import {
- Badge,
- Button,
- Popover,
- PopoverContent,
- PopoverItem,
- PopoverTrigger,
-} from '@/components/emcn'
-import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui'
+ Modal,
+ ModalBody,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ ModalTabs,
+ ModalTabsContent,
+ ModalTabsList,
+ ModalTabsTrigger,
+} from '@/components/emcn/components/modal/modal'
import { getEnv } from '@/lib/core/config/env'
import { createLogger } from '@/lib/logs/console/logger'
import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
-import { ChatDeploy } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat-deploy'
-import { DeployedWorkflowModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deployed-workflow-modal'
-import { DeploymentInfo } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deployment-info'
-import { TemplateDeploy } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/template-deploy'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
+import { ApiDeploy } from './components/api/api'
+import { ChatDeploy, type ExistingChat } from './components/chat/chat'
+import { GeneralDeploy } from './components/general/general'
+import { TemplateDeploy } from './components/template/template'
const logger = createLogger('DeployModal')
+
interface DeployModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workflowId: string | null
+ isDeployed: boolean
needsRedeployment: boolean
setNeedsRedeployment: (value: boolean) => void
deployedState: WorkflowState
@@ -44,12 +49,13 @@ interface WorkflowDeploymentInfo {
needsRedeployment: boolean
}
-type TabView = 'api' | 'versions' | 'chat' | 'template'
+type TabView = 'general' | 'api' | 'chat' | 'template'
export function DeployModal({
open,
onOpenChange,
workflowId,
+ isDeployed: isDeployedProp,
needsRedeployment,
setNeedsRedeployment,
deployedState,
@@ -59,7 +65,7 @@ export function DeployModal({
const deploymentStatus = useWorkflowRegistry((state) =>
state.getWorkflowDeploymentStatus(workflowId)
)
- const isDeployed = deploymentStatus?.isDeployed || false
+ const isDeployed = deploymentStatus?.isDeployed ?? isDeployedProp
const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isUndeploying, setIsUndeploying] = useState(false)
@@ -69,7 +75,7 @@ export function DeployModal({
workflowId ? state.workflows[workflowId] : undefined
)
const workflowWorkspaceId = workflowMetadata?.workspaceId ?? null
- const [activeTab, setActiveTab] = useState('api')
+ const [activeTab, setActiveTab] = useState('general')
const [chatSubmitting, setChatSubmitting] = useState(false)
const [apiDeployError, setApiDeployError] = useState(null)
const [chatExists, setChatExists] = useState(false)
@@ -78,25 +84,18 @@ export function DeployModal({
const [versions, setVersions] = useState([])
const [versionsLoading, setVersionsLoading] = useState(false)
- const [activatingVersion, setActivatingVersion] = useState(null)
- const [previewVersion, setPreviewVersion] = useState(null)
- const [previewing, setPreviewing] = useState(false)
- const [previewDeployedState, setPreviewDeployedState] = useState(null)
- const [currentPage, setCurrentPage] = useState(1)
- const itemsPerPage = 5
- const [editingVersion, setEditingVersion] = useState(null)
- const [editValue, setEditValue] = useState('')
- const [isRenaming, setIsRenaming] = useState(false)
- const [openDropdown, setOpenDropdown] = useState(null)
- const [versionToActivate, setVersionToActivate] = useState(null)
- const inputRef = useRef(null)
+ const [showUndeployConfirm, setShowUndeployConfirm] = useState(false)
+ const [templateFormValid, setTemplateFormValid] = useState(false)
+ const [templateSubmitting, setTemplateSubmitting] = useState(false)
+ const [hasExistingTemplate, setHasExistingTemplate] = useState(false)
+ const [templateStatus, setTemplateStatus] = useState<{
+ status: 'pending' | 'approved' | 'rejected' | null
+ views?: number
+ stars?: number
+ } | null>(null)
- useEffect(() => {
- if (editingVersion !== null && inputRef.current) {
- inputRef.current.focus()
- inputRef.current.select()
- }
- }, [editingVersion])
+ const [existingChat, setExistingChat] = useState(null)
+ const [isLoadingChat, setIsLoadingChat] = useState(false)
const getApiKeyLabel = (value?: string | null) => {
if (value && value.trim().length > 0) {
@@ -112,41 +111,48 @@ export function DeployModal({
return getInputFormatExampleUtil(includeStreaming, selectedStreamingOutputs)
}
- const fetchChatDeploymentInfo = async () => {
- if (!open || !workflowId) return
+ const fetchChatDeploymentInfo = useCallback(async () => {
+ if (!workflowId) return
try {
- setIsLoading(true)
+ setIsLoadingChat(true)
const response = await fetch(`/api/workflows/${workflowId}/chat/status`)
if (response.ok) {
const data = await response.json()
if (data.isDeployed && data.deployment) {
- setChatExists(true)
+ const detailResponse = await fetch(`/api/chat/manage/${data.deployment.id}`)
+ if (detailResponse.ok) {
+ const chatDetail = await detailResponse.json()
+ setExistingChat(chatDetail)
+ setChatExists(true)
+ } else {
+ setExistingChat(null)
+ setChatExists(false)
+ }
} else {
+ setExistingChat(null)
setChatExists(false)
}
} else {
+ setExistingChat(null)
setChatExists(false)
}
} catch (error) {
logger.error('Error fetching chat deployment info:', { error })
+ setExistingChat(null)
setChatExists(false)
} finally {
- setIsLoading(false)
+ setIsLoadingChat(false)
}
- }
+ }, [workflowId])
useEffect(() => {
- if (open) {
- setIsLoading(true)
+ if (open && workflowId) {
+ setActiveTab('general')
fetchChatDeploymentInfo()
- setActiveTab('api')
- setVersionToActivate(null)
- } else {
- setVersionToActivate(null)
}
- }, [open, workflowId])
+ }, [open, workflowId, fetchChatDeploymentInfo])
useEffect(() => {
async function fetchDeploymentInfo() {
@@ -199,12 +205,7 @@ export function DeployModal({
try {
setIsSubmitting(true)
- let deployEndpoint = `/api/workflows/${workflowId}/deploy`
- if (versionToActivate !== null) {
- deployEndpoint = `/api/workflows/${workflowId}/deployments/${versionToActivate}/activate`
- }
-
- const response = await fetch(deployEndpoint, {
+ const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -221,17 +222,15 @@ export function DeployModal({
const responseData = await response.json()
- const isActivating = versionToActivate !== null
- const isDeployedStatus = isActivating ? true : (responseData.isDeployed ?? false)
+ const isDeployedStatus = responseData.isDeployed ?? false
const deployedAtTime = responseData.deployedAt ? new Date(responseData.deployedAt) : undefined
const apiKeyLabel = getApiKeyLabel(responseData.apiKey)
setDeploymentStatus(workflowId, isDeployedStatus, deployedAtTime, apiKeyLabel)
- const isActivatingVersion = versionToActivate !== null
- setNeedsRedeployment(isActivatingVersion)
+ setNeedsRedeployment(false)
if (workflowId) {
- useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, isActivatingVersion)
+ useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
}
await refetchDeployedState()
@@ -250,15 +249,11 @@ export function DeployModal({
apiKey: getApiKeyLabel(deploymentData.apiKey),
endpoint: apiEndpoint,
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`,
- needsRedeployment: isActivatingVersion,
+ needsRedeployment: false,
})
}
- setVersionToActivate(null)
setApiDeployError(null)
-
- // Templates connected to this workflow are automatically updated with the new state
- // The deployWorkflow function handles updating template states in db-helpers.ts
} catch (error: unknown) {
logger.error('Error deploying workflow:', { error })
const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow'
@@ -268,10 +263,9 @@ export function DeployModal({
}
}
- const fetchVersions = async () => {
+ const fetchVersions = useCallback(async () => {
if (!workflowId) return
try {
- setVersionsLoading(true)
const res = await fetch(`/api/workflows/${workflowId}/deployments`)
if (res.ok) {
const data = await res.json()
@@ -281,18 +275,16 @@ export function DeployModal({
}
} catch {
setVersions([])
- } finally {
- setVersionsLoading(false)
}
- }
+ }, [workflowId])
useEffect(() => {
if (open && workflowId) {
- fetchVersions()
+ setVersionsLoading(true)
+ fetchVersions().finally(() => setVersionsLoading(false))
}
- }, [open, workflowId])
+ }, [open, workflowId, fetchVersions])
- // Clean up selectedStreamingOutputs when blocks are deleted
useEffect(() => {
if (!open || selectedStreamingOutputs.length === 0) return
@@ -300,7 +292,6 @@ export function DeployModal({
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i
const validOutputs = selectedStreamingOutputs.filter((outputId) => {
- // If it starts with a UUID, extract the blockId and check if the block exists
if (UUID_REGEX.test(outputId)) {
const underscoreIndex = outputId.indexOf('_')
if (underscoreIndex === -1) return false
@@ -310,7 +301,6 @@ export function DeployModal({
return !!block
}
- // If it's in blockName.attribute format, check if a block with that name exists
const parts = outputId.split('.')
if (parts.length >= 2) {
const blockName = parts[0]
@@ -323,13 +313,11 @@ export function DeployModal({
return true
})
- // Update the state if any outputs were filtered out
if (validOutputs.length !== selectedStreamingOutputs.length) {
setSelectedStreamingOutputs(validOutputs)
}
}, [open, selectedStreamingOutputs, setSelectedStreamingOutputs])
- // Listen for event to reopen deploy modal
useEffect(() => {
const handleOpenDeployModal = (event: Event) => {
const customEvent = event as CustomEvent<{ tab?: TabView }>
@@ -346,73 +334,72 @@ export function DeployModal({
}
}, [onOpenChange])
- const handleActivateVersion = (version: number) => {
- setVersionToActivate(version)
- setActiveTab('api')
- }
+ const handlePromoteToLive = useCallback(
+ async (version: number) => {
+ if (!workflowId) return
- const openVersionPreview = async (version: number) => {
- if (!workflowId) return
- try {
- setPreviewing(true)
- setPreviewVersion(version)
- const res = await fetch(`/api/workflows/${workflowId}/deployments/${version}`)
- if (res.ok) {
- const data = await res.json()
- setPreviewDeployedState(data.deployedState || null)
- } else {
- setPreviewDeployedState(null)
+ // Optimistically update versions to show the new active version immediately
+ const previousVersions = [...versions]
+ setVersions((prev) =>
+ prev.map((v) => ({
+ ...v,
+ isActive: v.version === version,
+ }))
+ )
+
+ try {
+ const response = await fetch(
+ `/api/workflows/${workflowId}/deployments/${version}/activate`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ }
+ )
+
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData.error || 'Failed to promote version')
+ }
+
+ const responseData = await response.json()
+
+ const deployedAtTime = responseData.deployedAt
+ ? new Date(responseData.deployedAt)
+ : undefined
+ const apiKeyLabel = getApiKeyLabel(responseData.apiKey)
+
+ setDeploymentStatus(workflowId, true, deployedAtTime, apiKeyLabel)
+
+ // Refresh deployed state in background (no loading flash)
+ refetchDeployedState()
+ fetchVersions()
+
+ const deploymentInfoResponse = await fetch(`/api/workflows/${workflowId}/deploy`)
+ if (deploymentInfoResponse.ok) {
+ const deploymentData = await deploymentInfoResponse.json()
+ const apiEndpoint = `${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute`
+ const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
+ const placeholderKey = getApiHeaderPlaceholder()
+
+ setDeploymentInfo({
+ isDeployed: deploymentData.isDeployed,
+ deployedAt: deploymentData.deployedAt,
+ apiKey: getApiKeyLabel(deploymentData.apiKey),
+ endpoint: apiEndpoint,
+ exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`,
+ needsRedeployment: false,
+ })
+ }
+ } catch (error) {
+ // Rollback optimistic update on error
+ setVersions(previousVersions)
+ throw error
}
- } finally {
- // keep modal open even if error; user can close
- }
- }
-
- const handleStartRename = (version: number, currentName: string | null | undefined) => {
- setOpenDropdown(null) // Close dropdown first
- setEditingVersion(version)
- setEditValue(currentName || `v${version}`)
- }
-
- const handleSaveRename = async (version: number) => {
- if (!workflowId || !editValue.trim()) {
- setEditingVersion(null)
- return
- }
-
- const currentVersion = versions.find((v) => v.version === version)
- const currentName = currentVersion?.name || `v${version}`
-
- if (editValue.trim() === currentName) {
- setEditingVersion(null)
- return
- }
-
- setIsRenaming(true)
- try {
- const res = await fetch(`/api/workflows/${workflowId}/deployments/${version}`, {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name: editValue.trim() }),
- })
-
- if (res.ok) {
- await fetchVersions()
- setEditingVersion(null)
- } else {
- logger.error('Failed to rename version')
- }
- } catch (error) {
- logger.error('Error renaming version:', error)
- } finally {
- setIsRenaming(false)
- }
- }
-
- const handleCancelRename = () => {
- setEditingVersion(null)
- setEditValue('')
- }
+ },
+ [workflowId, versions, refetchDeployedState, fetchVersions, selectedStreamingOutputs]
+ )
const handleUndeploy = async () => {
try {
@@ -429,6 +416,7 @@ export function DeployModal({
setDeploymentStatus(workflowId, false)
setChatExists(false)
+ setShowUndeployConfirm(false)
onOpenChange(false)
} catch (error: unknown) {
logger.error('Error undeploying workflow:', { error })
@@ -490,8 +478,6 @@ export function DeployModal({
const handlePostDeploymentUpdate = async () => {
if (!workflowId) return
- const isActivating = versionToActivate !== null
-
setDeploymentStatus(workflowId, true, new Date(), getApiKeyLabel())
const deploymentInfoResponse = await fetch(`/api/workflows/${workflowId}/deploy`)
@@ -508,19 +494,18 @@ export function DeployModal({
apiKey: getApiKeyLabel(deploymentData.apiKey),
endpoint: apiEndpoint,
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`,
- needsRedeployment: isActivating,
+ needsRedeployment: false,
})
}
await refetchDeployedState()
await fetchVersions()
- useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, isActivating)
+ useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
}
const handleChatFormSubmit = () => {
const form = document.getElementById('chat-deploy-form') as HTMLFormElement
if (form) {
- // Check if we're in success view and need to trigger update
const updateTrigger = form.querySelector('[data-update-trigger]') as HTMLButtonElement
if (updateTrigger) {
updateTrigger.click()
@@ -530,325 +515,124 @@ export function DeployModal({
}
}
+ const handleChatDelete = () => {
+ const form = document.getElementById('chat-deploy-form') as HTMLFormElement
+ if (form) {
+ const deleteButton = form.querySelector('[data-delete-trigger]') as HTMLButtonElement
+ if (deleteButton) {
+ deleteButton.click()
+ }
+ }
+ }
+
+ const handleTemplateFormSubmit = useCallback(() => {
+ const form = document.getElementById('template-deploy-form') as HTMLFormElement
+ form?.requestSubmit()
+ }, [])
+
+ const handleTemplateDelete = useCallback(() => {
+ const form = document.getElementById('template-deploy-form')
+ const deleteTrigger = form?.querySelector('[data-template-delete-trigger]') as HTMLButtonElement
+ deleteTrigger?.click()
+ }, [])
+
return (
<>
-
-
-
-
-
- Deploy Workflow
- {needsRedeployment && versions.length > 0 && versionToActivate === null && (
-
- {versions.find((v) => v.isActive)?.name ||
- `v${versions.find((v) => v.isActive)?.version}`}{' '}
- active
-
- )}
-
-
-
- Close
-
-
-
+
+
+ Deploy Workflow
-
-
-
- setActiveTab('api')}
- >
- API
-
- setActiveTab('chat')}
- >
- Chat
-
- setActiveTab('versions')}
- >
- Versions
-
- setActiveTab('template')}
- >
- Template
-
-
-
+
setActiveTab(value as TabView)}
+ className='flex min-h-0 flex-1 flex-col'
+ >
+
+ General
+ API
+ Chat
+ Template
+
-
-
- {activeTab === 'api' && (
-
- {apiDeployError && (
-
-
API Deployment Error
-
{apiDeployError}
-
- )}
+
+
+
+
- {versionToActivate !== null ? (
-
-
- {`Deploy version ${
- versions.find((v) => v.version === versionToActivate)?.name ||
- `v${versionToActivate}`
- } to production.`}
-
-
-
- {isSubmitting ? (
- <>
-
- Deploying...
- >
- ) : (
- 'Deploy version'
- )}
-
- setVersionToActivate(null)}>
- Cancel
-
-
-
- ) : (
-
- )}
-
- )}
+
+
+
- {activeTab === 'versions' && (
- <>
-
Deployment Versions
- {versionsLoading ? (
-
- Loading deployments...
-
- ) : versions.length === 0 ? (
-
- No deployments yet
-
- ) : (
- <>
-
- {versions.length > itemsPerPage && (
-
-
- Showing{' '}
- {Math.min((currentPage - 1) * itemsPerPage + 1, versions.length)} -{' '}
- {Math.min(currentPage * itemsPerPage, versions.length)} of{' '}
- {versions.length}
-
-
- setCurrentPage(currentPage - 1)}
- disabled={currentPage === 1}
- >
- Previous
-
- setCurrentPage(currentPage + 1)}
- disabled={currentPage * itemsPerPage >= versions.length}
- >
- Next
-
-
-
- )}
- >
- )}
- >
- )}
+
+ {}}
+ />
+
- {activeTab === 'chat' && (
-
+ {workflowId && (
+ setVersionToActivate(null)}
+ onValidationChange={setTemplateFormValid}
+ onSubmittingChange={setTemplateSubmitting}
+ onExistingTemplateChange={setHasExistingTemplate}
+ onTemplateStatusChange={setTemplateStatus}
/>
)}
+
+
+
- {activeTab === 'template' && workflowId && (
-
- )}
-
-
-
-
+ {activeTab === 'general' && (
+ setShowUndeployConfirm(true)}
+ />
+ )}
{activeTab === 'chat' && (
-
-
- Cancel
-
-
+
{chatExists && (
{
- const form = document.getElementById('chat-deploy-form') as HTMLFormElement
- if (form) {
- const deleteButton = form.querySelector(
- '[data-delete-trigger]'
- ) as HTMLButtonElement
- if (deleteButton) {
- deleteButton.click()
- }
- }
- }}
+ variant='default'
+ onClick={handleChatDelete}
disabled={chatSubmitting}
- className='bg-red-500 text-white hover:bg-red-600'
>
Delete
@@ -859,49 +643,215 @@ export function DeployModal({
onClick={handleChatFormSubmit}
disabled={chatSubmitting || !isChatFormValid}
>
- {chatSubmitting ? (
- <>
-
- Deploying...
- >
- ) : chatExists ? (
- 'Update'
- ) : (
- 'Deploy Chat'
- )}
+ {chatSubmitting
+ ? chatExists
+ ? 'Updating...'
+ : 'Launching...'
+ : chatExists
+ ? 'Update'
+ : 'Launch Chat'}
-
+
)}
-
- {previewVersion !== null && previewDeployedState && workflowId && (
- {
- setPreviewVersion(null)
- setPreviewDeployedState(null)
- setPreviewing(false)
- }}
- needsRedeployment={true}
- activeDeployedState={deployedState}
- selectedDeployedState={previewDeployedState as WorkflowState}
- selectedVersion={previewVersion}
- onActivateVersion={() => {
- handleActivateVersion(previewVersion)
- setPreviewVersion(null)
- setPreviewDeployedState(null)
- setPreviewing(false)
- }}
- isActivating={activatingVersion === previewVersion}
- selectedVersionLabel={
- versions.find((v) => v.version === previewVersion)?.name || `v${previewVersion}`
- }
- workflowId={workflowId}
- isSelectedVersionActive={versions.find((v) => v.version === previewVersion)?.isActive}
- onLoadDeploymentComplete={handleCloseModal}
- />
- )}
-
+ {activeTab === 'template' && (
+
+ {hasExistingTemplate && templateStatus && (
+
+ )}
+
+ {hasExistingTemplate && (
+
+ Delete
+
+ )}
+
+ {templateSubmitting
+ ? hasExistingTemplate
+ ? 'Updating...'
+ : 'Publishing...'
+ : hasExistingTemplate
+ ? 'Update Template'
+ : 'Publish Template'}
+
+
+
+ )}
+
+
+
+
+
+ Undeploy API
+
+
+ Are you sure you want to undeploy this workflow?{' '}
+
+ This will remove the API endpoint and make it unavailable to external users.
+
+
+
+
+ setShowUndeployConfirm(false)}
+ disabled={isUndeploying}
+ >
+ Cancel
+
+
+ {isUndeploying ? 'Undeploying...' : 'Undeploy'}
+
+
+
+
>
)
}
+
+interface StatusBadgeProps {
+ isWarning: boolean
+}
+
+function StatusBadge({ isWarning }: StatusBadgeProps) {
+ const label = isWarning ? 'Update deployment' : 'Live'
+
+ return (
+
+ )
+}
+
+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 (
+
+
+
+ {label}
+
+ {statsText && (
+
+ • {statsText}
+
+ )}
+
+ )
+}
+
+interface GeneralFooterProps {
+ isDeployed?: boolean
+ needsRedeployment: boolean
+ isSubmitting: boolean
+ isUndeploying: boolean
+ onDeploy: () => Promise
+ onRedeploy: () => Promise
+ onUndeploy: () => void
+}
+
+function GeneralFooter({
+ isDeployed,
+ needsRedeployment,
+ isSubmitting,
+ isUndeploying,
+ onDeploy,
+ onRedeploy,
+ onUndeploy,
+}: GeneralFooterProps) {
+ if (!isDeployed) {
+ return (
+
+
+ {isSubmitting ? 'Deploying...' : 'Deploy API'}
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ {isUndeploying ? 'Undeploying...' : 'Undeploy'}
+
+ {needsRedeployment && (
+
+ {isSubmitting ? 'Updating...' : 'Update'}
+
+ )}
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/index.ts
new file mode 100644
index 000000000..3399969b9
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/index.ts
@@ -0,0 +1 @@
+export { DeployModal } from './deploy-modal/deploy-modal'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx
index e76ea18fe..7c2253351 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx
@@ -62,7 +62,6 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP
const isEmpty = !hasBlocks()
const canDeploy = userPermissions.canAdmin
const isDisabled = isDeploying || !canDeploy || isEmpty
- const isPreviousVersionActive = isDeployed && changeDetected
/**
* Handle deploy button click
@@ -135,6 +134,7 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP
open={isModalOpen}
onOpenChange={setIsModalOpen}
workflowId={activeWorkflowId}
+ isDeployed={isDeployed}
needsRedeployment={changeDetected}
setNeedsRedeployment={setChangeDetected}
deployedState={deployedState!}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/hooks/use-chat-deployment.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/hooks/use-chat-deployment.ts
deleted file mode 100644
index 321110c49..000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/hooks/use-chat-deployment.ts
+++ /dev/null
@@ -1,156 +0,0 @@
-import { useCallback, useState } from 'react'
-import { z } from 'zod'
-import { createLogger } from '@/lib/logs/console/logger'
-import type { ChatFormData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/hooks/use-chat-form'
-import type { OutputConfig } from '@/stores/chat/store'
-
-const logger = createLogger('ChatDeployment')
-
-export interface ChatDeploymentState {
- isLoading: boolean
- error: string | null
- deployedUrl: string | null
-}
-
-const chatSchema = z.object({
- workflowId: z.string().min(1, 'Workflow ID is required'),
- identifier: z
- .string()
- .min(1, 'Identifier is required')
- .regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens'),
- title: z.string().min(1, 'Title is required'),
- description: z.string().optional(),
- customizations: z.object({
- primaryColor: z.string(),
- welcomeMessage: z.string(),
- imageUrl: z.string().optional(),
- }),
- authType: z.enum(['public', 'password', 'email']).default('public'),
- password: z.string().optional(),
- allowedEmails: z.array(z.string()).optional().default([]),
- outputConfigs: z
- .array(
- z.object({
- blockId: z.string(),
- path: z.string(),
- })
- )
- .optional()
- .default([]),
-})
-
-export function useChatDeployment() {
- const [state, setState] = useState({
- isLoading: false,
- error: null,
- deployedUrl: null,
- })
-
- const deployChat = useCallback(
- async (
- workflowId: string,
- formData: ChatFormData,
- deploymentInfo: { apiKey: string } | null,
- existingChatId?: string,
- imageUrl?: string | null
- ) => {
- setState({ isLoading: true, error: null, deployedUrl: null })
-
- try {
- // Prepare output configs
- const outputConfigs: OutputConfig[] = formData.selectedOutputBlocks
- .map((outputId) => {
- const firstUnderscoreIndex = outputId.indexOf('_')
- if (firstUnderscoreIndex !== -1) {
- const blockId = outputId.substring(0, firstUnderscoreIndex)
- const path = outputId.substring(firstUnderscoreIndex + 1)
- if (blockId && path) {
- return { blockId, path }
- }
- }
- return null
- })
- .filter(Boolean) as OutputConfig[]
-
- const payload = {
- workflowId,
- identifier: formData.identifier.trim(),
- title: formData.title.trim(),
- description: formData.description.trim(),
- customizations: {
- primaryColor: 'var(--brand-primary-hover-hex)',
- welcomeMessage: formData.welcomeMessage.trim(),
- ...(imageUrl && { imageUrl }),
- },
- authType: formData.authType,
- password: formData.authType === 'password' ? formData.password : undefined,
- allowedEmails:
- formData.authType === 'email' || formData.authType === 'sso' ? formData.emails : [],
- outputConfigs,
- apiKey: deploymentInfo?.apiKey,
- deployApiEnabled: !existingChatId,
- }
-
- chatSchema.parse(payload)
-
- // Determine endpoint and method
- const endpoint = existingChatId ? `/api/chat/manage/${existingChatId}` : '/api/chat'
- const method = existingChatId ? 'PATCH' : 'POST'
-
- const response = await fetch(endpoint, {
- method,
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
- })
-
- const result = await response.json()
-
- if (!response.ok) {
- // Handle identifier conflict specifically
- if (result.error === 'Identifier already in use') {
- throw new Error('This identifier is already in use')
- }
- throw new Error(result.error || `Failed to ${existingChatId ? 'update' : 'deploy'} chat`)
- }
-
- if (!result.chatUrl) {
- throw new Error('Response missing chatUrl')
- }
-
- setState({
- isLoading: false,
- error: null,
- deployedUrl: result.chatUrl,
- })
-
- logger.info(`Chat ${existingChatId ? 'updated' : 'deployed'} successfully:`, result.chatUrl)
- return result.chatUrl
- } catch (error: any) {
- const errorMessage = error.message || 'An unexpected error occurred'
- setState({
- isLoading: false,
- error: errorMessage,
- deployedUrl: null,
- })
-
- logger.error(`Failed to ${existingChatId ? 'update' : 'deploy'} chat:`, error)
- throw error
- }
- },
- []
- )
-
- const reset = useCallback(() => {
- setState({
- isLoading: false,
- error: null,
- deployedUrl: null,
- })
- }, [])
-
- return {
- ...state,
- deployChat,
- reset,
- }
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/hooks/use-chat-form.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/hooks/use-chat-form.ts
deleted file mode 100644
index 3c73581d7..000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/hooks/use-chat-form.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-import { useCallback, useState } from 'react'
-
-export type AuthType = 'public' | 'password' | 'email' | 'sso'
-
-export interface ChatFormData {
- identifier: string
- title: string
- description: string
- authType: AuthType
- password: string
- emails: string[]
- welcomeMessage: string
- selectedOutputBlocks: string[]
-}
-
-export interface ChatFormErrors {
- identifier?: string
- title?: string
- password?: string
- emails?: string
- outputBlocks?: string
- general?: string
-}
-
-const initialFormData: ChatFormData = {
- identifier: '',
- title: '',
- description: '',
- authType: 'public',
- password: '',
- emails: [],
- welcomeMessage: 'Hi there! How can I help you today?',
- selectedOutputBlocks: [],
-}
-
-export function useChatForm(initialData?: Partial) {
- const [formData, setFormData] = useState({
- ...initialFormData,
- ...initialData,
- })
-
- const [errors, setErrors] = useState({})
-
- const updateField = useCallback(
- (field: K, value: ChatFormData[K]) => {
- setFormData((prev) => ({ ...prev, [field]: value }))
- // Clear error when user starts typing
- if (field in errors && errors[field as keyof ChatFormErrors]) {
- setErrors((prev) => ({ ...prev, [field]: undefined }))
- }
- },
- [errors]
- )
-
- const setError = useCallback((field: keyof ChatFormErrors, message: string) => {
- setErrors((prev) => ({ ...prev, [field]: message }))
- }, [])
-
- const clearError = useCallback((field: keyof ChatFormErrors) => {
- setErrors((prev) => ({ ...prev, [field]: undefined }))
- }, [])
-
- const clearAllErrors = useCallback(() => {
- setErrors({})
- }, [])
-
- const validateForm = useCallback((): boolean => {
- const newErrors: ChatFormErrors = {}
-
- if (!formData.identifier.trim()) {
- newErrors.identifier = 'Identifier is required'
- } else if (!/^[a-z0-9-]+$/.test(formData.identifier)) {
- newErrors.identifier = 'Identifier can only contain lowercase letters, numbers, and hyphens'
- }
-
- if (!formData.title.trim()) {
- newErrors.title = 'Title is required'
- }
-
- if (formData.authType === 'password' && !formData.password.trim()) {
- newErrors.password = 'Password is required when using password protection'
- }
-
- if (formData.authType === 'email' && formData.emails.length === 0) {
- newErrors.emails = 'At least one email or domain is required when using email access control'
- }
-
- if (formData.authType === 'sso' && formData.emails.length === 0) {
- newErrors.emails = 'At least one email or domain is required when using SSO access control'
- }
-
- if (formData.selectedOutputBlocks.length === 0) {
- newErrors.outputBlocks = 'Please select at least one output block'
- }
-
- setErrors(newErrors)
- return Object.keys(newErrors).length === 0
- }, [formData])
-
- const resetForm = useCallback(() => {
- setFormData(initialFormData)
- setErrors({})
- }, [])
-
- return {
- formData,
- errors,
- updateField,
- setError,
- clearError,
- clearAllErrors,
- validateForm,
- resetForm,
- setFormData,
- }
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/components/field-item/field-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/components/field-item/field-item.tsx
index 1f0276ed7..44f881c62 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/components/field-item/field-item.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/components/field-item/field-item.tsx
@@ -91,7 +91,7 @@ export function FieldItem({
onDragStart={handleDragStart}
onClick={handleClick}
className={clsx(
- 'group flex h-[25px] cursor-grab items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] hover:bg-[var(--border)] active:cursor-grabbing dark:hover:bg-[var(--border)]',
+ 'group flex h-[25px] cursor-grab items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] hover:bg-[var(--border)] active:cursor-grabbing',
hasChildren && 'cursor-pointer'
)}
style={{ marginLeft: `${indent}px` }}
@@ -99,7 +99,7 @@ export function FieldItem({
{field.name}
@@ -109,7 +109,7 @@ export function FieldItem({
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/connection-blocks.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/connection-blocks.tsx
index 1205a02c8..61a8cd91a 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/connection-blocks.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/connection-blocks.tsx
@@ -25,7 +25,7 @@ interface ConnectionBlocksProps {
}
const TREE_STYLES = {
- LINE_COLOR: '#2C2C2C',
+ LINE_COLOR: 'var(--border)',
LINE_OFFSET: 4,
} as const
@@ -123,7 +123,7 @@ function ConnectionItem({
draggable
onDragStart={(e) => onConnectionDragStart(e, connection)}
className={clsx(
- 'group flex h-[25px] cursor-grab items-center gap-[8px] rounded-[8px] px-[5.5px] text-[14px] hover:bg-[var(--border)] active:cursor-grabbing dark:hover:bg-[var(--border)]',
+ 'group flex h-[25px] cursor-grab items-center gap-[8px] rounded-[8px] px-[5.5px] text-[14px] hover:bg-[var(--border)] active:cursor-grabbing',
hasFields && 'cursor-pointer'
)}
onClick={() => hasFields && onToggleExpand(connection.id)}
@@ -145,7 +145,7 @@ function ConnectionItem({
{connection.name}
@@ -154,7 +154,7 @@ function ConnectionItem({
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx
index 3336dadf8..44f6e13a0 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx
@@ -633,8 +633,12 @@ export function Code({
numbers.push(
{lineNumber}
@@ -656,8 +660,10 @@ export function Code({
numbers.push(
1
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx
index c215ae644..edab988d4 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx
@@ -709,7 +709,7 @@ export function ConditionInput({
{conditionalBlocks.map((block, index) => (
+
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx
index 33a4d64cb..4d1301880 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx
@@ -12,6 +12,7 @@ import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/
import type { SubBlockConfig } from '@/blocks/types'
const MIN_TEXTAREA_HEIGHT_PX = 80
+const MAX_TEXTAREA_HEIGHT_PX = 320
/**
* Interface for individual message in the messages array
@@ -236,10 +237,12 @@ export function MessagesInput({
return
}
- // Auto-resize to fit content only when user hasn't manually resized.
textarea.style.height = 'auto'
const naturalHeight = textarea.scrollHeight || MIN_TEXTAREA_HEIGHT_PX
- const nextHeight = Math.max(MIN_TEXTAREA_HEIGHT_PX, naturalHeight)
+ const nextHeight = Math.min(
+ MAX_TEXTAREA_HEIGHT_PX,
+ Math.max(MIN_TEXTAREA_HEIGHT_PX, naturalHeight)
+ )
textarea.style.height = `${nextHeight}px`
if (overlay) {
@@ -453,7 +456,7 @@ export function MessagesInput({
ref={(el) => {
textareaRefs.current[fieldId] = el
}}
- className='allow-scroll box-border min-h-[80px] w-full resize-none whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-[inherit] font-medium text-sm text-transparent leading-[inherit] caret-[var(--text-primary)] outline-none placeholder:text-[var(--text-muted)] focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed'
+ className='allow-scroll box-border min-h-[80px] w-full resize-none whitespace-pre-wrap break-words border-none bg-transparent px-[8px] pt-[8px] font-[inherit] font-medium text-sm text-transparent leading-[inherit] caret-[var(--text-primary)] outline-none placeholder:text-[var(--text-muted)] focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed'
rows={3}
placeholder='Enter message content...'
value={message.content}
@@ -496,7 +499,7 @@ export function MessagesInput({
ref={(el) => {
overlayRefs.current[fieldId] = el
}}
- className='pointer-events-none absolute top-0 left-0 box-border w-full overflow-auto whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-[inherit] font-medium text-[var(--text-primary)] text-sm leading-[inherit]'
+ className='scrollbar-none pointer-events-none absolute top-0 left-0 box-border w-full overflow-auto whitespace-pre-wrap break-words border-none bg-transparent px-[8px] pt-[8px] font-[inherit] font-medium text-[var(--text-primary)] text-sm leading-[inherit]'
>
{formatDisplayText(message.content, {
accessiblePrefixes,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/schedule-save/schedule-save.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/schedule-save/schedule-save.tsx
index 2d18d2e9c..771442b01 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/schedule-save/schedule-save.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/schedule-save/schedule-save.tsx
@@ -485,9 +485,7 @@ export function ScheduleSave({ blockId, isPreview = false, disabled = false }: S
Are you sure you want to delete this schedule configuration? This will stop the
workflow from running automatically.{' '}
-
- This action cannot be undone.
-
+ This action cannot be undone.
@@ -500,7 +498,7 @@ export function ScheduleSave({ blockId, isPreview = false, disabled = false }: S
Delete
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx
index 8d6524e48..33b2c105d 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx
@@ -332,7 +332,7 @@ export function FieldFormat({
{
- '[\n {\n "data": "data:application/pdf;base64,...",\n "type": "file",\n "name": "document.pdf",\n "mime": "application/pdf"\n }\n]'
+ '[\n {\n "data": "",\n "type": "file",\n "name": "document.pdf",\n "mime": "application/pdf"\n }\n]'
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table/table.tsx
index ad3595f7c..34de2cc5d 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table/table.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table/table.tsx
@@ -57,8 +57,9 @@ export function Table({
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
- // Create refs for input elements
+ // Create refs for input and overlay elements
const inputRefs = useRef>(new Map())
+ const overlayRefs = useRef>(new Map())
// Memoized template for empty cells for current columns
const emptyCellsTemplate = useMemo(
@@ -180,16 +181,42 @@ export function Table({
cellValue,
(newValue) => updateCellValue(rowIndex, column, newValue)
)
- const tagSelectHandler = inputController.fieldHelpers.createTagSelectHandler(
+ const handleScroll = (e: React.UIEvent) => {
+ const overlay = overlayRefs.current.get(cellKey)
+ if (overlay) {
+ overlay.scrollLeft = e.currentTarget.scrollLeft
+ }
+ }
+
+ const syncScrollAfterUpdate = () => {
+ requestAnimationFrame(() => {
+ const input = inputRefs.current.get(cellKey)
+ const overlay = overlayRefs.current.get(cellKey)
+ if (input && overlay) {
+ overlay.scrollLeft = input.scrollLeft
+ }
+ })
+ }
+
+ const baseTagSelectHandler = inputController.fieldHelpers.createTagSelectHandler(
cellKey,
cellValue,
(newValue) => updateCellValue(rowIndex, column, newValue)
)
- const envVarSelectHandler = inputController.fieldHelpers.createEnvVarSelectHandler(
+ const tagSelectHandler = (tag: string) => {
+ baseTagSelectHandler(tag)
+ syncScrollAfterUpdate()
+ }
+
+ const baseEnvVarSelectHandler = inputController.fieldHelpers.createEnvVarSelectHandler(
cellKey,
cellValue,
(newValue) => updateCellValue(rowIndex, column, newValue)
)
+ const envVarSelectHandler = (envVar: string) => {
+ baseEnvVarSelectHandler(envVar)
+ syncScrollAfterUpdate()
+ }
return (
{
+ if (el) overlayRefs.current.set(cellKey, el)
+ }}
data-overlay={cellKey}
- className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-[10px] font-medium text-[#eeeeee] text-sm'
+ className='scrollbar-hide pointer-events-none absolute top-0 right-[10px] bottom-0 left-[10px] overflow-x-auto overflow-y-hidden bg-transparent'
>
-
+
{formatDisplayText(cellValue, {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
@@ -279,7 +310,7 @@ export function Table({
return (
-
+
{renderHeader()}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx
index 2c68e264f..b9795506f 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx
@@ -20,6 +20,7 @@ interface CodeEditorProps {
language: 'javascript' | 'json'
placeholder?: string
className?: string
+ gutterClassName?: string
minHeight?: string
highlightVariables?: boolean
onKeyDown?: (e: React.KeyboardEvent) => void
@@ -36,6 +37,7 @@ export function CodeEditor({
language,
placeholder = '',
className = '',
+ gutterClassName = '',
minHeight = '360px',
highlightVariables = true,
onKeyDown,
@@ -180,7 +182,9 @@ export function CodeEditor({
)}
- {renderLineNumbers()}
+
+ {renderLineNumbers()}
+
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx
index fbdde6032..a62477dcc 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx
@@ -1,14 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react'
-import { AlertCircle, Code, FileJson, Wand2, X } from 'lucide-react'
+import { AlertCircle, Wand2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
- Button as EmcnButton,
- Modal,
- ModalContent,
- ModalDescription,
- ModalFooter,
- ModalHeader,
- ModalTitle,
+ Button,
Popover,
PopoverAnchor,
PopoverContent,
@@ -16,16 +10,17 @@ import {
PopoverScrollArea,
PopoverSection,
} from '@/components/emcn'
-import { Trash } from '@/components/emcn/icons/trash'
-import { Button } from '@/components/ui/button'
import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
+ Modal,
+ ModalBody,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ ModalTabs,
+ ModalTabsContent,
+ ModalTabsList,
+ ModalTabsTrigger,
+} from '@/components/emcn/components/modal/modal'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/core/utils/cn'
import { createLogger } from '@/lib/logs/console/logger'
@@ -347,10 +342,7 @@ try {
}
}
- const validateFunctionCode = (code: string): boolean => {
- return true // Allow empty code
- }
-
+ /** Extracts parameters from JSON schema for autocomplete */
const schemaParameters = useMemo(() => {
try {
if (!jsonSchema) return []
@@ -369,8 +361,8 @@ try {
}
}, [jsonSchema])
+ /** Memoized schema validation result */
const isSchemaValid = useMemo(() => validateJsonSchema(jsonSchema), [jsonSchema])
- const isCodeValid = useMemo(() => validateFunctionCode(functionCode), [functionCode])
const handleSave = async () => {
try {
@@ -835,53 +827,11 @@ try {
}
}
- const navigationItems = [
- {
- id: 'schema' as const,
- label: 'Schema',
- icon: FileJson,
- complete: isSchemaValid,
- },
- {
- id: 'code' as const,
- label: 'Code',
- icon: Code,
- complete: isCodeValid,
- },
- ]
-
- useEffect(() => {
- if (!open) return
-
- const styleId = 'custom-tool-modal-z-index'
- let styleEl = document.getElementById(styleId) as HTMLStyleElement
-
- if (!styleEl) {
- styleEl = document.createElement('style')
- styleEl.id = styleId
- styleEl.textContent = `
- [data-radix-portal] [data-radix-dialog-overlay] {
- z-index: 10000048 !important;
- }
- `
- document.head.appendChild(styleEl)
- }
-
- return () => {
- const el = document.getElementById(styleId)
- if (el) {
- el.remove()
- }
- }
- }, [open])
-
return (
<>
-
-
+ {
if (e.key === 'Escape' && (showEnvVars || showTags || showSchemaParams)) {
e.preventDefault()
@@ -892,57 +842,27 @@ try {
}
}}
>
-
-
-
- {isEditing ? 'Edit Agent Tool' : 'Create Agent Tool'}
-
-
-
- Close
-
-
-
- Step {activeSection === 'schema' ? '1' : '2'} of 2:{' '}
- {activeSection === 'schema' ? 'Define schema' : 'Implement code'}
-
-
+ {isEditing ? 'Edit Agent Tool' : 'Create Agent Tool'}
-
-
- {navigationItems.map((item) => (
- setActiveSection(item.id)}
- className={cn(
- 'flex items-center gap-2 border-b-2 px-6 py-3 text-sm transition-colors',
- 'hover:bg-muted/50',
- activeSection === item.id
- ? 'border-primary font-medium text-foreground'
- : 'border-transparent text-muted-foreground hover:text-foreground'
- )}
- >
-
- {item.label}
-
- ))}
-
+
setActiveSection(value as ToolSection)}
+ className='flex min-h-0 flex-1 flex-col'
+ >
+
+ Schema
+ Code
+
-
-
+
+
-
-
+
JSON Schema
{schemaError && (
-
+
@@ -950,7 +870,7 @@ try {
{!isSchemaPromptActive && schemaPromptSummary && (
-
+
with {schemaPromptSummary}
)}
@@ -973,19 +893,18 @@ try {
onBlur={handleSchemaPromptBlur}
onKeyDown={handleSchemaPromptKeyDown}
disabled={schemaGeneration.isStreaming}
- className='h-[16px] w-full border-none bg-transparent py-0 pr-[2px] text-right font-medium text-[12px] text-[var(--text-primary)] leading-[14px] placeholder:text-[#737373] focus:outline-none'
+ className='h-[16px] w-full border-none bg-transparent py-0 pr-[2px] text-right font-medium text-[12px] text-[var(--text-primary)] leading-[14px] placeholder:text-[var(--text-muted)] focus:outline-none'
placeholder='Describe schema...'
/>
)}
-
-
-
-
-
+ minHeight='420px'
+ className={cn(
+ 'bg-[var(--bg)]',
+ schemaError && 'border-[var(--text-error)]',
+ (schemaGeneration.isLoading || schemaGeneration.isStreaming) &&
+ 'cursor-not-allowed opacity-50'
+ )}
+ gutterClassName='bg-[var(--bg)]'
+ disabled={schemaGeneration.isLoading || schemaGeneration.isStreaming}
+ onKeyDown={handleKeyDown}
+ />
+
-
+
-
-
+
Code
+ {codeError && !codeGeneration.isStreaming && (
+
+ )}
{!isCodePromptActive && codePromptSummary && (
-
+
with {codePromptSummary}
)}
@@ -1053,23 +972,19 @@ try {
onBlur={handleCodePromptBlur}
onKeyDown={handleCodePromptKeyDown}
disabled={codeGeneration.isStreaming}
- className='h-[16px] w-full border-none bg-transparent py-0 pr-[2px] text-right font-medium text-[12px] text-[var(--text-primary)] leading-[14px] placeholder:text-[#737373] focus:outline-none'
+ className='h-[16px] w-full border-none bg-transparent py-0 pr-[2px] text-right font-medium text-[12px] text-[var(--text-primary)] leading-[14px] placeholder:text-[var(--text-muted)] focus:outline-none'
placeholder='Describe code...'
/>
)}
- {codeError &&
- !codeGeneration.isStreaming && ( // Hide code error while streaming
-
{codeError}
- )}
{schemaParameters.length > 0 && (
-
-
+
+
Available parameters: {' '}
{schemaParameters.map((param, index) => (
-
+
{param.name}
{index < schemaParameters.length - 1 && ', '}
@@ -1085,22 +1000,21 @@ try {
onChange={handleFunctionCodeChange}
language='javascript'
showWandButton={false}
- placeholder={
- '// This code will be executed when the tool is called. You can use environment variables with {{VARIABLE_NAME}}.'
- }
- minHeight='360px'
+ placeholder={'return schemaVariable + {{environmentVariable}}'}
+ minHeight={schemaParameters.length > 0 ? '380px' : '420px'}
className={cn(
- codeError && !codeGeneration.isStreaming ? 'border-red-500' : '',
+ 'bg-[var(--bg)]',
+ codeError && !codeGeneration.isStreaming && 'border-[var(--text-error)]',
(codeGeneration.isLoading || codeGeneration.isStreaming) &&
'cursor-not-allowed opacity-50'
)}
+ gutterClassName='bg-[var(--bg)]'
highlightVariables={true}
- disabled={codeGeneration.isLoading || codeGeneration.isStreaming} // Use disabled prop instead of readOnly
- onKeyDown={handleKeyDown} // Pass keydown handler
- schemaParameters={schemaParameters} // Pass schema parameters for highlighting
+ disabled={codeGeneration.isLoading || codeGeneration.isStreaming}
+ onKeyDown={handleKeyDown}
+ schemaParameters={schemaParameters}
/>
- {/* Environment variables dropdown */}
{showEnvVars && (
)}
- {/* Tags dropdown */}
{showTags && (
)}
- {/* Schema parameters dropdown */}
{showSchemaParams && schemaParameters.length > 0 && (
e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
@@ -1198,7 +1109,9 @@ try {
P
{param.name}
-
{param.type}
+
+ {param.type}
+
))}
@@ -1206,90 +1119,92 @@ try {
)}
-
-
-
-
+
+
+
-
-
+ {activeSection === 'schema' && (
+
{isEditing ? (
- setShowDeleteConfirm(true)}
+ className='bg-[var(--text-error)] text-white hover:bg-[var(--text-error)]'
>
-
Delete
-
+
) : (
- {
- if (activeSection === 'code') {
- setActiveSection('schema')
- }
- }}
- disabled={activeSection === 'schema'}
- >
- Back
-
+
)}
-
-
+
+
Cancel
-
- {activeSection === 'schema' ? (
- setActiveSection('code')}
- disabled={!isSchemaValid || !!schemaError}
- >
- Next
-
- ) : (
-
- {isEditing ? 'Update Tool' : 'Save Tool'}
-
- )}
+
+ setActiveSection('code')}
+ disabled={!isSchemaValid || !!schemaError}
+ >
+ Next
+
-
-
-
-
+
+ )}
+
+ {activeSection === 'code' && (
+
+ {isEditing ? (
+ setShowDeleteConfirm(true)}
+ className='bg-[var(--text-error)] text-white hover:bg-[var(--text-error)]'
+ >
+ Delete
+
+ ) : (
+ setActiveSection('schema')}>
+ Back
+
+ )}
+
+
+ Cancel
+
+
+ {isEditing ? 'Update Tool' : 'Save Tool'}
+
+
+
+ )}
+
+
- {/* Delete Confirmation Dialog */}
-
-
- Delete custom tool?
-
+
+ Delete Custom Tool
+
+
This will permanently delete the tool and remove it from any workflows that are using
- it.{' '}
-
- This action cannot be undone.
-
-
-
+ it. This action cannot be undone.
+
+
setShowDeleteConfirm(false)}
disabled={deleteToolMutation.isPending}
>
Cancel
{deleteToolMutation.isPending ? 'Deleting...' : 'Delete'}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx
index 8bbdbf396..763638a5b 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx
@@ -1,20 +1,20 @@
import type React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
-import { Loader2, PlusIcon, Server, WrenchIcon, XIcon } from 'lucide-react'
+import { Loader2, PlusIcon, WrenchIcon, XIcon } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Combobox,
Popover,
PopoverContent,
+ PopoverItem,
PopoverScrollArea,
PopoverSearch,
PopoverSection,
PopoverTrigger,
- Tooltip,
} from '@/components/emcn'
+import { McpIcon } from '@/components/icons'
import { Switch } from '@/components/ui/switch'
-import { Toggle } from '@/components/ui/toggle'
import { cn } from '@/lib/core/utils/cn'
import { createLogger } from '@/lib/logs/console/logger'
import {
@@ -1601,7 +1601,7 @@ export function ToolInput({
{selectedTools.length === 0 ? (
-
+
Add Tool
@@ -1631,9 +1631,7 @@ export function ToolInput({
}}
disabled={isPreview}
>
-
-
-
+
Create Tool
@@ -1649,9 +1647,7 @@ export function ToolInput({
}}
disabled={isPreview}
>
-
-
-
+
Add MCP Server
@@ -1822,7 +1818,7 @@ export function ToolInput({
) : isMcpTool ? (
-
+
) : (
)}
-
+
{tool.title}
{supportsToolControl && (
-
-
- {}}
- onClick={(e: React.MouseEvent) => {
- e.stopPropagation()
- const currentState = tool.usageControl || 'auto'
- const nextState =
- currentState === 'auto'
- ? 'force'
- : currentState === 'force'
- ? 'none'
- : 'auto'
- handleUsageControlChange(toolIndex, nextState)
- }}
- aria-label='Toggle tool usage control'
+
+
+ e.stopPropagation()}
+ aria-label='Tool usage control'
>
-
- Auto
-
-
- Force
-
-
- None
-
-
-
-
-
- {tool.usageControl === 'auto' && (
-
- The model decides when to use the
- tool
-
- )}
- {tool.usageControl === 'force' && (
-
- Always use this tool in the
- response
-
- )}
- {tool.usageControl === 'none' && (
-
- Never use this tool
-
- )}
-
-
-
+ {tool.usageControl === 'auto' && 'Auto'}
+ {tool.usageControl === 'force' && 'Force'}
+ {tool.usageControl === 'none' && 'None'}
+ {!tool.usageControl && 'Auto'}
+
+
+
e.stopPropagation()}
+ className='gap-[2px]'
+ >
+ handleUsageControlChange(toolIndex, 'auto')}
+ >
+ Auto{' '}
+ (model decides)
+
+ handleUsageControlChange(toolIndex, 'force')}
+ >
+ Force (always use)
+
+ handleUsageControlChange(toolIndex, 'none')}
+ >
+ None
+
+
+
)}
{
e.stopPropagation()
handleRemoveTool(toolIndex)
}}
- className='text-[var(--text-tertiary)] transition-colors hover:text-[#EEEEEE]'
+ className='flex items-center justify-center text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
aria-label='Remove tool'
>
-
+
@@ -2143,7 +2116,7 @@ export function ToolInput({
{/* Add Tool Button */}
-
+
Add Tool
@@ -2170,9 +2143,7 @@ export function ToolInput({
setCustomToolModalOpen(true)
}}
>
-
-
-
+
Create Tool
@@ -2185,9 +2156,7 @@ export function ToolInput({
)
}}
>
-
-
-
+
Add MCP Server
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/trigger-save/trigger-save.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/trigger-save/trigger-save.tsx
index 6991ee621..dba143582 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/trigger-save/trigger-save.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/trigger-save/trigger-save.tsx
@@ -469,7 +469,7 @@ export function TriggerSave({
Delete
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx
index a905da653..81e061273 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx
@@ -294,7 +294,7 @@ export function VariablesInput({
key={assignment.id}
data-assignment-id={assignment.id}
className={cn(
- 'rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F]',
+ 'rounded-[4px] border border-[var(--border-strong)] bg-[var(--surface-3)] dark:bg-[#1F1F1F]',
collapsed ? 'overflow-hidden' : 'overflow-visible'
)}
>
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx
index 33e497d96..c5ef35523 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx
@@ -235,7 +235,7 @@ const renderLabel = (
}}
disabled={isStreaming}
className={cn(
- 'h-[12px] w-full max-w-[200px] border-none bg-transparent py-0 pr-[2px] text-right font-medium text-[12px] text-[var(--text-primary)] leading-[14px] placeholder:text-[#737373] focus:outline-none dark:text-[var(--text-primary)]',
+ 'h-[12px] w-full max-w-[200px] border-none bg-transparent py-0 pr-[2px] text-right font-medium text-[12px] text-[var(--text-primary)] leading-[14px] placeholder:text-[var(--text-muted)] focus:outline-none',
isStreaming && 'text-muted-foreground'
)}
placeholder='Describe...'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/subflow-editor/subflow-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/subflow-editor/subflow-editor.tsx
index 73aa68867..7046dcf00 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/subflow-editor/subflow-editor.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/subflow-editor/subflow-editor.tsx
@@ -71,7 +71,7 @@ export function SubflowEditor({
{/* Type Selection */}
-
+
{currentBlock.type === 'loop' ? 'Loop Type' : 'Parallel Type'}
{/* Configuration */}
-
+
{isCountMode
? `${currentBlock.type === 'loop' ? 'Loop' : 'Parallel'} Iterations`
: isConditionMode
@@ -165,7 +165,7 @@ export function SubflowEditor({
{hasIncomingConnections && (
-
- Connections
-
+ Connections
{/* Connections Content - Always visible */}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx
index d04231859..531207547 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx
@@ -183,7 +183,7 @@ export function Editor() {
return (
{/* Header */}
-
+
{(blockConfig || isSubflow) && (
) : (
{
@@ -363,7 +363,7 @@ export function Editor() {
className='h-[1.25px]'
style={{
backgroundImage:
- 'repeating-linear-gradient(to right, #2C2C2C 0px, #2C2C2C 6px, transparent 6px, transparent 12px)',
+ 'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
@@ -380,7 +380,7 @@ export function Editor() {
{hasIncomingConnections && (
-
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components/drag-preview.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components/drag-preview.ts
index e09e765b4..1ea9bf4e8 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components/drag-preview.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components/drag-preview.ts
@@ -18,7 +18,7 @@ export function createDragPreview(info: DragItemInfo): HTMLElement {
const preview = document.createElement('div')
preview.style.cssText = `
width: 250px;
- background: #232323;
+ background: var(--surface-1);
border-radius: 8px;
padding: 8px 9px;
display: flex;
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx
index ba0640805..ec6be45d9 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx
@@ -488,12 +488,10 @@ export const Toolbar = forwardRef
(function Toolbar(
>
{/* Header */}
-
- Toolbar
-
+
Toolbar
{!isSearchActive ? (
(function Toolbar(
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onBlur={handleSearchBlur}
- className='w-full border-none bg-transparent pr-[2px] text-right font-medium text-[13px] text-[var(--text-primary)] placeholder:text-[#737373] focus:outline-none dark:text-[var(--text-primary)]'
+ className='w-full border-none bg-transparent pr-[2px] text-right font-medium text-[13px] text-[var(--text-primary)] placeholder:text-[#737373] focus:outline-none'
/>
)}
@@ -529,7 +527,7 @@ export const Toolbar = forwardRef
(function Toolbar(
>
Triggers
@@ -556,9 +554,9 @@ export const Toolbar = forwardRef(function Toolbar(
}}
onClick={() => handleItemClick(trigger.type, isTriggerCapable)}
className={clsx(
- 'group flex h-[25px] items-center gap-[8px] rounded-[8px] px-[5px] text-[14px]',
- 'cursor-pointer hover:bg-[var(--border)] active:cursor-grabbing dark:hover:bg-[var(--border)]',
- 'focus-visible:bg-[var(--border)] focus-visible:outline-none dark:focus-visible:bg-[var(--border)]'
+ 'group flex h-[25px] items-center gap-[8px] rounded-[8px] px-[5.5px] text-[14px]',
+ 'cursor-pointer hover:bg-[var(--surface-9)] active:cursor-grabbing',
+ 'focus-visible:bg-[var(--surface-9)] focus-visible:outline-none'
)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
@@ -569,7 +567,7 @@ export const Toolbar = forwardRef(function Toolbar(
}}
>
{Icon && (
@@ -577,7 +575,7 @@ export const Toolbar = forwardRef(function Toolbar(
className={clsx(
'toolbar-item-icon text-white transition-transform duration-200',
'group-hover:scale-110',
- '!h-[10px] !w-[10px]'
+ '!h-[9px] !w-[9px]'
)}
/>
)}
@@ -585,8 +583,8 @@ export const Toolbar = forwardRef(function Toolbar(
{trigger.name}
@@ -599,7 +597,7 @@ export const Toolbar = forwardRef(function Toolbar(
{/* Resize Handle */}
-
+
(function Toolbar(
Blocks
@@ -646,8 +644,8 @@ export const Toolbar = forwardRef
(function Toolbar(
onClick={() => handleItemClick(block.type, false)}
className={clsx(
'group flex h-[25px] items-center gap-[8px] rounded-[8px] px-[5.5px] text-[14px]',
- 'cursor-pointer hover:bg-[var(--border)] active:cursor-grabbing dark:hover:bg-[var(--border)]',
- 'focus-visible:bg-[var(--border)] focus-visible:outline-none dark:focus-visible:bg-[var(--border)]'
+ 'cursor-pointer hover:bg-[var(--surface-9)] active:cursor-grabbing',
+ 'focus-visible:bg-[var(--surface-9)] focus-visible:outline-none'
)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
@@ -658,7 +656,7 @@ export const Toolbar = forwardRef(function Toolbar(
}}
>
{Icon && (
@@ -666,7 +664,7 @@ export const Toolbar = forwardRef
(function Toolbar(
className={clsx(
'toolbar-item-icon text-white transition-transform duration-200',
'group-hover:scale-110',
- '!h-[10px] !w-[10px]'
+ '!h-[9px] !w-[9px]'
)}
/>
)}
@@ -674,8 +672,8 @@ export const Toolbar = forwardRef(function Toolbar(
{block.name}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx
index bee0688e4..bbda443f4 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx
@@ -1,7 +1,7 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
-import { Braces, Square } from 'lucide-react'
+import { ArrowUp, Square } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import {
BubbleChatPreview,
@@ -43,7 +43,6 @@ import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspace
import { useChatStore } from '@/stores/chat/store'
import { usePanelStore } from '@/stores/panel/store'
import type { PanelTab } from '@/stores/panel/types'
-import { DEFAULT_TERMINAL_HEIGHT, MIN_TERMINAL_HEIGHT, useTerminalStore } from '@/stores/terminal'
import { useVariablesStore } from '@/stores/variables/store'
import { useWorkflowJsonStore } from '@/stores/workflows/json/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -143,10 +142,6 @@ export function Panel() {
openSubscriptionSettings()
return
}
- const { openOnRun, terminalHeight, setTerminalHeight } = useTerminalStore.getState()
- if (openOnRun && terminalHeight <= MIN_TERMINAL_HEIGHT) {
- setTerminalHeight(DEFAULT_TERMINAL_HEIGHT)
- }
await handleRunWorkflow()
}, [usageExceeded, handleRunWorkflow])
@@ -373,10 +368,10 @@ export function Panel() {
<>
-
+
{/* Header */}
{/* More and Chat */}
@@ -413,7 +408,7 @@ export function Panel() {
onClick={handleExportJson}
disabled={!userPermissions.canEdit || isExporting || !currentWorkflow}
>
-
+
Export workflow
handleTabClick('copilot')}
data-tab-button='copilot'
@@ -475,7 +470,7 @@ export function Panel() {
Copilot
handleTabClick('toolbar')}
data-tab-button='toolbar'
@@ -483,7 +478,7 @@ export function Panel() {
Toolbar
handleTabClick('editor')}
data-tab-button='editor'
@@ -555,9 +550,7 @@ export function Panel() {
Deleting this workflow will permanently remove all associated blocks, executions, and
configuration.{' '}
-
- This action cannot be undone.
-
+ This action cannot be undone.
@@ -570,7 +563,7 @@ export function Panel() {
Cancel
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx
index 3df3d38a3..303b83326 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx
@@ -25,7 +25,7 @@ const SubflowNodeStyles: React.FC = () => {
/* Drag-over states */
.loop-node-drag-over,
.parallel-node-drag-over {
- box-shadow: 0 0 0 1.75px #33B4FF !important;
+ box-shadow: 0 0 0 1.75px var(--brand-secondary) !important;
border-radius: 8px !important;
}
@@ -161,7 +161,7 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps {
e.stopPropagation()
@@ -216,7 +216,7 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps
- Start
+ Start
{
setIsResizing(true)
- }, [])
+ }, [setIsResizing])
/**
* Setup resize event listeners and body styles when resizing
@@ -57,7 +56,7 @@ export function useTerminalResize() {
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
- }, [isResizing, setTerminalHeight])
+ }, [isResizing, setTerminalHeight, setIsResizing])
return {
isResizing,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx
index 0a11ad7b1..7949d3d7b 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx
@@ -35,11 +35,7 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks'
import { getBlock } from '@/blocks'
import type { ConsoleEntry } from '@/stores/terminal'
-import {
- DEFAULT_TERMINAL_HEIGHT,
- useTerminalConsoleStore,
- useTerminalStore,
-} from '@/stores/terminal'
+import { useTerminalConsoleStore, useTerminalStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
/**
@@ -82,9 +78,8 @@ const RUN_ID_COLORS = [
/**
* Shared styling constants
*/
-const HEADER_TEXT_CLASS =
- 'font-medium text-[var(--text-tertiary)] text-[12px] dark:text-[var(--text-tertiary)]'
-const ROW_TEXT_CLASS = 'font-medium text-[#D2D2D2] text-[12px] dark:text-[#D2D2D2]'
+const HEADER_TEXT_CLASS = 'font-medium text-[var(--text-tertiary)] text-[12px]'
+const ROW_TEXT_CLASS = 'font-medium text-[var(--text-primary)] text-[12px]'
const COLUMN_BASE_CLASS = 'flex-shrink-0'
/**
@@ -254,9 +249,11 @@ const isEventFromEditableElement = (e: KeyboardEvent): boolean => {
export function Terminal() {
const terminalRef = useRef(null)
const prevEntriesLengthRef = useRef(0)
+ const prevWorkflowEntriesLengthRef = useRef(0)
const {
terminalHeight,
setTerminalHeight,
+ lastExpandedHeight,
outputPanelWidth,
setOutputPanelWidth,
openOnRun,
@@ -301,6 +298,22 @@ export function Terminal() {
const isExpanded = terminalHeight > NEAR_MIN_THRESHOLD
+ /**
+ * Expands the terminal to its last meaningful height, with safeguards:
+ * - Never expands below {@link DEFAULT_EXPANDED_HEIGHT}.
+ * - Never exceeds 70% of the viewport height.
+ */
+ const expandToLastHeight = useCallback(() => {
+ setIsToggling(true)
+ const maxHeight = window.innerHeight * 0.7
+ const desiredHeight = Math.max(
+ lastExpandedHeight || DEFAULT_EXPANDED_HEIGHT,
+ DEFAULT_EXPANDED_HEIGHT
+ )
+ const targetHeight = Math.min(desiredHeight, maxHeight)
+ setTerminalHeight(targetHeight)
+ }, [lastExpandedHeight, setTerminalHeight])
+
/**
* Get all entries for current workflow (before filtering) for filter options
*/
@@ -404,6 +417,28 @@ export function Terminal() {
return selectedEntry.output
}, [selectedEntry, showInput])
+ /**
+ * Auto-open the terminal on new entries when "Open on run" is enabled.
+ * This mirrors the header toggle behavior by using expandToLastHeight,
+ * ensuring we always get the same smooth height transition.
+ */
+ useEffect(() => {
+ if (!openOnRun) {
+ prevWorkflowEntriesLengthRef.current = allWorkflowEntries.length
+ return
+ }
+
+ const previousLength = prevWorkflowEntriesLengthRef.current
+ const currentLength = allWorkflowEntries.length
+
+ // Only react when new entries are added for the active workflow
+ if (currentLength > previousLength && terminalHeight <= MIN_HEIGHT) {
+ expandToLastHeight()
+ }
+
+ prevWorkflowEntriesLengthRef.current = currentLength
+ }, [allWorkflowEntries.length, expandToLastHeight, openOnRun, terminalHeight])
+
/**
* Handle row click - toggle if clicking same entry
* Disables auto-selection when user manually selects, re-enables when deselecting
@@ -421,14 +456,13 @@ export function Terminal() {
* Handle header click - toggle between expanded and collapsed
*/
const handleHeaderClick = useCallback(() => {
- setIsToggling(true)
-
if (isExpanded) {
+ setIsToggling(true)
setTerminalHeight(MIN_HEIGHT)
} else {
- setTerminalHeight(DEFAULT_TERMINAL_HEIGHT)
+ expandToLastHeight()
}
- }, [isExpanded, setTerminalHeight])
+ }, [expandToLastHeight, isExpanded, setTerminalHeight])
/**
* Handle transition end - reset toggling state
@@ -628,10 +662,7 @@ export function Terminal() {
e.preventDefault()
if (!isExpanded) {
- setIsToggling(true)
- const maxHeight = window.innerHeight * 0.7
- const targetHeight = Math.min(DEFAULT_EXPANDED_HEIGHT, maxHeight)
- setTerminalHeight(targetHeight)
+ expandToLastHeight()
}
if (e.key === 'ArrowLeft') {
@@ -647,7 +678,7 @@ export function Terminal() {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
- }, [selectedEntry, showInput, hasInputData, isExpanded])
+ }, [expandToLastHeight, selectedEntry, showInput, hasInputData, isExpanded])
/**
* Handle Escape to unselect and Enter to re-enable auto-selection
@@ -721,13 +752,13 @@ export function Terminal() {
-
+
{/* Left Section - Logs Table */}
Error
{filters.statuses.has('error') &&
}
@@ -839,7 +870,7 @@ export function Terminal() {
>
Info
{filters.statuses.has('info') &&
}
@@ -1053,8 +1084,8 @@ export function Terminal() {
handleRowClick(entry)}
>
@@ -1067,7 +1098,7 @@ export function Terminal() {
)}
>
{BlockIcon && (
-
+
)}
{entry.blockName}
@@ -1079,19 +1110,25 @@ export function Terminal() {
className={clsx(
'flex h-[24px] w-[56px] items-center justify-start rounded-[6px] border pl-[9px]',
statusInfo.isError
- ? 'gap-[5px] border-[#883827] bg-[#491515]'
- : 'gap-[8px] border-[#686868] bg-[#383838]'
+ ? 'gap-[5px] border-[var(--terminal-status-error-border)] bg-[var(--terminal-status-error-bg)]'
+ : 'gap-[8px] border-[var(--terminal-status-info-border)] bg-[var(--terminal-status-info-bg)]'
)}
>
{statusInfo.label}
@@ -1155,7 +1192,7 @@ export function Terminal() {
{/* Right Section - Block Output (Overlay) */}
{selectedEntry && (
{/* Horizontal Resize Handle */}
@@ -1184,10 +1221,7 @@ export function Terminal() {
onClick={(e) => {
e.stopPropagation()
if (!isExpanded) {
- setIsToggling(true)
- const maxHeight = window.innerHeight * 0.7
- const targetHeight = Math.min(DEFAULT_EXPANDED_HEIGHT, maxHeight)
- setTerminalHeight(targetHeight)
+ expandToLastHeight()
}
if (showInput) setShowInput(false)
}}
@@ -1205,10 +1239,7 @@ export function Terminal() {
onClick={(e) => {
e.stopPropagation()
if (!isExpanded) {
- setIsToggling(true)
- const maxHeight = window.innerHeight * 0.7
- const targetHeight = Math.min(DEFAULT_EXPANDED_HEIGHT, maxHeight)
- setTerminalHeight(targetHeight)
+ expandToLastHeight()
}
setShowInput(true)
}}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx
index bbd33012b..65b079061 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx
@@ -19,12 +19,23 @@ import { Label } from '@/components/emcn/components/label/label'
import { Trash } from '@/components/emcn/icons/trash'
import { cn } from '@/lib/core/utils/cn'
import { validateName } from '@/lib/core/utils/validation'
+import {
+ useFloatBoundarySync,
+ useFloatDrag,
+ useFloatResize,
+} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useVariablesStore as usePanelVariablesStore } from '@/stores/panel/variables/store'
-import { getVariablesPosition, useVariablesStore } from '@/stores/variables/store'
+import {
+ getVariablesPosition,
+ MAX_VARIABLES_HEIGHT,
+ MAX_VARIABLES_WIDTH,
+ MIN_VARIABLES_HEIGHT,
+ MIN_VARIABLES_WIDTH,
+ useVariablesStore,
+} from '@/stores/variables/store'
import type { Variable } from '@/stores/variables/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
-import { useChatBoundarySync, useChatDrag, useChatResize } from '../chat/hooks'
/**
* Type options for variable type selection
@@ -42,7 +53,7 @@ const TYPE_OPTIONS: ComboboxOption[] = [
*/
const BADGE_HEIGHT = 20
const BADGE_TEXT_SIZE = 13
-const ICON_SIZE = 14
+const ICON_SIZE = 13
const HEADER_ICON_SIZE = 16
const LINE_HEIGHT = 21
const MIN_EDITOR_HEIGHT = 120
@@ -97,14 +108,14 @@ export function Variables() {
[position, width, height]
)
- const { handleMouseDown } = useChatDrag({
+ const { handleMouseDown } = useFloatDrag({
position: actualPosition,
width,
height,
onPositionChange: setPosition,
})
- useChatBoundarySync({
+ useFloatBoundarySync({
isOpen,
position: actualPosition,
width,
@@ -117,12 +128,16 @@ export function Variables() {
handleMouseMove: handleResizeMouseMove,
handleMouseLeave: handleResizeMouseLeave,
handleMouseDown: handleResizeMouseDown,
- } = useChatResize({
+ } = useFloatResize({
position: actualPosition,
width,
height,
onPositionChange: setPosition,
onDimensionsChange: setDimensions,
+ minWidth: MIN_VARIABLES_WIDTH,
+ minHeight: MIN_VARIABLES_HEIGHT,
+ maxWidth: MAX_VARIABLES_WIDTH,
+ maxHeight: MAX_VARIABLES_HEIGHT,
})
const [collapsedById, setCollapsedById] = useState>({})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/webhook-settings/webhook-settings.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/webhook-settings/webhook-settings.tsx
index 89dec6140..fc430f8c9 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/webhook-settings/webhook-settings.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/webhook-settings/webhook-settings.tsx
@@ -1170,9 +1170,7 @@ export function WebhookSettings({ workflowId, open, onOpenChange }: WebhookSetti
Delete webhook?
This will permanently remove the webhook configuration and stop all notifications.{' '}
-
- This action cannot be undone.
-
+ This action cannot be undone.
@@ -1187,7 +1185,7 @@ export function WebhookSettings({ workflowId, open, onOpenChange }: WebhookSetti
{isDeleting ? 'Deleting...' : 'Delete'}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx
index 7452dd4ef..500cf9880 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx
@@ -90,7 +90,7 @@ export const ActionBar = memo(
collaborativeToggleBlockEnabled(blockId)
}
}}
- className='h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)]'
+ className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)]'
disabled={disabled}
>
{isEnabled ? (
@@ -116,7 +116,7 @@ export const ActionBar = memo(
collaborativeDuplicateBlock(blockId)
}
}}
- className='h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)]'
+ className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)]'
disabled={disabled}
>
@@ -139,7 +139,7 @@ export const ActionBar = memo(
)
}
}}
- className='h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)]'
+ className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)]'
disabled={disabled || !userPermissions.canEdit}
>
@@ -159,7 +159,7 @@ export const ActionBar = memo(
collaborativeToggleBlockHandles(blockId)
}
}}
- className='h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)]'
+ className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)]'
disabled={disabled}
>
{horizontalHandles ? (
@@ -184,7 +184,7 @@ export const ActionBar = memo(
collaborativeRemoveBlock(blockId)
}
}}
- className='h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)] '
+ className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)]'
disabled={disabled}
>
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx
index 1d7eb9963..79c652ed4 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx
@@ -24,7 +24,7 @@ import {
getProviderName,
shouldSkipBlockRender,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
-import { useBlockCore } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
+import { useBlockVisual } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import {
BLOCK_DIMENSIONS,
useBlockDimensions,
@@ -404,7 +404,7 @@ const SubBlockRow = ({
{displayValue !== undefined && (
{displayValue}
@@ -430,15 +430,11 @@ export const WorkflowBlock = memo(function WorkflowBlock({
currentWorkflow,
activeWorkflowId,
isEnabled,
- isActive,
- diffStatus,
- isDeletedBlock,
- isFocused,
handleClick,
hasRing,
ringStyles,
runPathStatus,
- } = useBlockCore({ blockId: id, data, isPending })
+ } = useBlockVisual({ blockId: id, data, isPending })
const currentBlock = currentWorkflow.getBlockById(id)
@@ -856,7 +852,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
@@ -874,8 +870,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
variant='outline'
className='cursor-pointer'
style={{
- borderColor: !childIsDeployed ? '#EF4444' : '#FF6600',
- color: !childIsDeployed ? '#EF4444' : '#FF6600',
+ borderColor: !childIsDeployed ? 'var(--text-error)' : 'var(--warning)',
+ color: !childIsDeployed ? 'var(--text-error)' : 'var(--warning)',
}}
onClick={(e) => {
e.stopPropagation()
@@ -903,8 +899,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
variant='outline'
className='cursor-pointer'
style={{
- borderColor: '#FF6600',
- color: '#FF6600',
+ borderColor: 'var(--warning)',
+ color: 'var(--warning)',
}}
onClick={(e) => {
e.stopPropagation()
@@ -957,7 +953,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
{
e.stopPropagation()
reactivateWebhook(webhookId)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx
index dcd94d429..bb5b972b7 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx
@@ -141,7 +141,7 @@ export const WorkflowEdge = ({
}
}}
>
-
+
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts
index 51c1ae8a8..4292f8743 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts
@@ -1,7 +1,8 @@
export { useAutoLayout } from './use-auto-layout'
-export { useBlockCore } from './use-block-core'
export { BLOCK_DIMENSIONS, useBlockDimensions } from './use-block-dimensions'
+export { useBlockVisual } from './use-block-visual'
export { type CurrentWorkflow, useCurrentWorkflow } from './use-current-workflow'
+export { useFloatBoundarySync, useFloatDrag, useFloatResize } from './use-float'
export { useNodeUtilities } from './use-node-utilities'
export { useScrollManagement } from './use-scroll-management'
export { useWorkflowExecution } from './use-workflow-execution'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-core.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-visual.ts
similarity index 54%
rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-core.ts
rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-visual.ts
index 7625dc88c..7fd80df20 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-core.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-visual.ts
@@ -7,72 +7,72 @@ import { useExecutionStore } from '@/stores/execution/store'
import { usePanelEditorStore } from '@/stores/panel/editor/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
-interface UseBlockCoreOptions {
+/**
+ * Props for the useBlockVisual hook.
+ */
+interface UseBlockVisualProps {
+ /** The unique identifier of the block */
blockId: string
+ /** Block data including type, config, and preview state */
data: WorkflowBlockProps
+ /** Whether the block is pending execution */
isPending?: boolean
}
/**
- * Consolidated hook for core block functionality shared across all block types.
- * Combines workflow state, block state, focus, and ring styling.
+ * Provides visual state and interaction handlers for workflow blocks.
+ * Computes ring styling based on execution, focus, diff, and run path states.
+ * In preview mode, all interactive and execution-related visual states are disabled.
+ *
+ * @param props - The hook properties
+ * @returns Visual state, click handler, and ring styling for the block
*/
-export function useBlockCore({ blockId, data, isPending = false }: UseBlockCoreOptions) {
- // Workflow context
+export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVisualProps) {
+ const isPreview = data.isPreview ?? false
+
const currentWorkflow = useCurrentWorkflow()
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
- // Block state (enabled, active, diff status, deleted)
- const { isEnabled, isActive, diffStatus, isDeletedBlock } = useBlockState(
- blockId,
- currentWorkflow,
- data
- )
+ const {
+ isEnabled,
+ isActive: blockIsActive,
+ diffStatus,
+ isDeletedBlock,
+ } = useBlockState(blockId, currentWorkflow, data)
+
+ const isActive = isPreview ? false : blockIsActive
- // Run path state (from last execution)
const lastRunPath = useExecutionStore((state) => state.lastRunPath)
- const runPathStatus = lastRunPath.get(blockId)
+ const runPathStatus = isPreview ? undefined : lastRunPath.get(blockId)
- // Focus management
const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
- const isFocused = currentBlockId === blockId
+ const isFocused = isPreview ? false : currentBlockId === blockId
const handleClick = useCallback(() => {
- setCurrentBlockId(blockId)
- }, [blockId, setCurrentBlockId])
+ if (!isPreview) {
+ setCurrentBlockId(blockId)
+ }
+ }, [blockId, setCurrentBlockId, isPreview])
- // Ring styling based on all states
- // Priority: active (executing) > pending > focused > deleted > diff > run path
const { hasRing, ringClassName: ringStyles } = useMemo(
() =>
getBlockRingStyles({
isActive,
- isPending,
+ isPending: isPreview ? false : isPending,
isFocused,
- isDeletedBlock,
- diffStatus,
+ isDeletedBlock: isPreview ? false : isDeletedBlock,
+ diffStatus: isPreview ? undefined : diffStatus,
runPathStatus,
}),
- [isActive, isPending, isFocused, isDeletedBlock, diffStatus, runPathStatus]
+ [isActive, isPending, isFocused, isDeletedBlock, diffStatus, runPathStatus, isPreview]
)
return {
- // Workflow context
currentWorkflow,
activeWorkflowId,
-
- // Block state
isEnabled,
- isActive,
- diffStatus,
- isDeletedBlock,
-
- // Focus
- isFocused,
handleClick,
-
- // Ring styling
hasRing,
ringStyles,
runPathStatus,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/index.ts
new file mode 100644
index 000000000..c487cd896
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/index.ts
@@ -0,0 +1,3 @@
+export { useFloatBoundarySync } from './use-float-boundary-sync'
+export { useFloatDrag } from './use-float-drag'
+export { useFloatResize } from './use-float-resize'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-boundary-sync.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/use-float-boundary-sync.ts
similarity index 92%
rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-boundary-sync.ts
rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/use-float-boundary-sync.ts
index 54be33257..a9fbfcd06 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-boundary-sync.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/use-float-boundary-sync.ts
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef } from 'react'
-interface UseChatBoundarySyncProps {
+interface UseFloatBoundarySyncProps {
isOpen: boolean
position: { x: number; y: number }
width: number
@@ -9,17 +9,17 @@ interface UseChatBoundarySyncProps {
}
/**
- * Hook to synchronize chat position with layout boundary changes
- * Keeps chat within bounds when sidebar, panel, or terminal resize
+ * Hook to synchronize floats position with layout boundary changes.
+ * Keeps the float within bounds when sidebar, panel, or terminal resize.
* Uses requestAnimationFrame for smooth real-time updates
*/
-export function useChatBoundarySync({
+export function useFloatBoundarySync({
isOpen,
position,
width,
height,
onPositionChange,
-}: UseChatBoundarySyncProps) {
+}: UseFloatBoundarySyncProps) {
const rafIdRef = useRef
(null)
const positionRef = useRef(position)
const previousDimensionsRef = useRef({ sidebarWidth: 0, panelWidth: 0, terminalHeight: 0 })
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-drag.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/use-float-drag.ts
similarity index 92%
rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-drag.ts
rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/use-float-drag.ts
index 643e1d6a8..cc71da957 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-drag.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/use-float-drag.ts
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef } from 'react'
import { constrainChatPosition } from '@/stores/chat/store'
-interface UseChatDragProps {
+interface UseFloatDragProps {
position: { x: number; y: number }
width: number
height: number
@@ -9,10 +9,10 @@ interface UseChatDragProps {
}
/**
- * Hook for handling drag functionality of floating chat modal
+ * Hook for handling drag functionality of floats.
* Provides mouse event handlers and manages drag state
*/
-export function useChatDrag({ position, width, height, onPositionChange }: UseChatDragProps) {
+export function useFloatDrag({ position, width, height, onPositionChange }: UseFloatDragProps) {
const isDraggingRef = useRef(false)
const dragStartRef = useRef({ x: 0, y: 0 })
const initialPositionRef = useRef({ x: 0, y: 0 })
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/use-float-resize.ts
similarity index 90%
rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-resize.ts
rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/use-float-resize.ts
index 08a3e17d2..33c6e7223 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-resize.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/use-float-resize.ts
@@ -6,12 +6,20 @@ import {
MIN_CHAT_WIDTH,
} from '@/stores/chat/store'
-interface UseChatResizeProps {
+interface UseFloatResizeProps {
position: { x: number; y: number }
width: number
height: number
onPositionChange: (position: { x: number; y: number }) => void
onDimensionsChange: (dimensions: { width: number; height: number }) => void
+ /**
+ * Optional dimension constraints.
+ * If omitted, chat defaults are used for backward compatibility.
+ */
+ minWidth?: number
+ minHeight?: number
+ maxWidth?: number
+ maxHeight?: number
}
/**
@@ -34,16 +42,20 @@ type ResizeDirection =
const EDGE_THRESHOLD = 8
/**
- * Hook for handling multi-directional resize functionality of floating chat modal
- * Supports resizing from all 8 directions: 4 corners and 4 edges
+ * Hook for handling multi-directional resize functionality of floating panels.
+ * Supports resizing from all 8 directions: 4 corners and 4 edges.
*/
-export function useChatResize({
+export function useFloatResize({
position,
width,
height,
onPositionChange,
onDimensionsChange,
-}: UseChatResizeProps) {
+ minWidth,
+ minHeight,
+ maxWidth,
+ maxHeight,
+}: UseFloatResizeProps) {
const [cursor, setCursor] = useState('')
const isResizingRef = useRef(false)
const activeDirectionRef = useRef(null)
@@ -285,9 +297,18 @@ export function useChatResize({
break
}
- // Constrain dimensions to min/max
- const constrainedWidth = Math.max(MIN_CHAT_WIDTH, Math.min(MAX_CHAT_WIDTH, newWidth))
- const constrainedHeight = Math.max(MIN_CHAT_HEIGHT, Math.min(MAX_CHAT_HEIGHT, newHeight))
+ // Constrain dimensions to min/max. If explicit constraints are not provided,
+ // fall back to the chat defaults for backward compatibility.
+ const effectiveMinWidth = typeof minWidth === 'number' ? minWidth : MIN_CHAT_WIDTH
+ const effectiveMaxWidth = typeof maxWidth === 'number' ? maxWidth : MAX_CHAT_WIDTH
+ const effectiveMinHeight = typeof minHeight === 'number' ? minHeight : MIN_CHAT_HEIGHT
+ const effectiveMaxHeight = typeof maxHeight === 'number' ? maxHeight : MAX_CHAT_HEIGHT
+
+ const constrainedWidth = Math.max(effectiveMinWidth, Math.min(effectiveMaxWidth, newWidth))
+ const constrainedHeight = Math.max(
+ effectiveMinHeight,
+ Math.min(effectiveMaxHeight, newHeight)
+ )
// Adjust position if dimensions were constrained on left/top edges
if (direction === 'top-left' || direction === 'bottom-left' || direction === 'left') {
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts
index 8b6cc5f1f..1b532c694 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts
@@ -49,7 +49,7 @@ export function getBlockRingStyles(options: BlockRingOptions): {
!isFocused &&
!isDeletedBlock &&
diffStatus === 'new' &&
- 'ring-[#22C55E]',
+ 'ring-[var(--brand-tertiary)]',
!isActive &&
!isPending &&
!isFocused &&
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
index 13fc26e11..ca2d8a1d6 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
@@ -308,7 +308,7 @@ const WorkflowContent = React.memo(() => {
*/
const connectionLineStyle = useMemo(() => {
return {
- stroke: isErrorConnectionDrag ? '#EF4444' : '#434343',
+ stroke: isErrorConnectionDrag ? 'var(--text-error)' : 'var(--surface-12)',
strokeWidth: 2,
}
}, [isErrorConnectionDrag])
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/footer-navigation/footer-navigation.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/footer-navigation/footer-navigation.tsx
index df82ed754..b973e85d1 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/footer-navigation/footer-navigation.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/footer-navigation/footer-navigation.tsx
@@ -81,30 +81,28 @@ export function FooterNavigation() {
return (
<>
-
+
{navigationItems.map((item) => {
const Icon = item.icon
const active = item.href ? isActive(item.href) : false
const itemClasses = clsx(
- 'group flex h-[24px] items-center gap-[8px] rounded-[8px] px-[7px] text-[14px]',
- active
- ? 'bg-[var(--border)] dark:bg-[var(--border)]'
- : 'hover:bg-[var(--border)] dark:hover:bg-[var(--border)]'
+ 'group flex h-[25px] items-center gap-[8px] rounded-[8px] px-[5.5px] text-[14px]',
+ active ? 'bg-[var(--surface-9)]' : 'hover:bg-[var(--surface-9)]'
)
const iconClasses = clsx(
'h-[14px] w-[14px] flex-shrink-0',
active
- ? 'text-[var(--text-primary)] dark:text-[var(--text-primary)]'
- : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)] dark:text-[var(--text-tertiary)] dark:group-hover:text-[var(--text-primary)]'
+ ? 'text-[var(--text-primary)]'
+ : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
)
const labelClasses = clsx(
- 'truncate font-base text-[13px]',
+ 'truncate font-medium text-[13px]',
active
- ? 'text-[var(--text-primary)] dark:text-[var(--text-primary)]'
- : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)] dark:text-[var(--text-tertiary)] dark:group-hover:text-[var(--text-primary)]'
+ ? 'text-[var(--text-primary)]'
+ : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
)
const content = (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/help-modal/help-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/help-modal/help-modal.tsx
index e29dcc402..7b65d744d 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/help-modal/help-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/help-modal/help-modal.tsx
@@ -3,19 +3,18 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import imageCompression from 'browser-image-compression'
-import { Loader2, X } from 'lucide-react'
+import { X } from 'lucide-react'
import Image from 'next/image'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
+import { Button, Combobox, Input, Textarea } from '@/components/emcn'
import {
- Button,
- Combobox,
- Input,
Modal,
+ ModalBody,
ModalContent,
- ModalTitle,
- Textarea,
-} from '@/components/emcn'
+ ModalFooter,
+ ModalHeader,
+} from '@/components/emcn/components/modal/modal'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/core/utils/cn'
import { createLogger } from '@/lib/logs/console/logger'
@@ -60,13 +59,10 @@ interface HelpModalProps {
export function HelpModal({ open, onOpenChange }: HelpModalProps) {
const fileInputRef = useRef
(null)
const scrollContainerRef = useRef(null)
- const dropZoneRef = useRef(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [submitStatus, setSubmitStatus] = useState<'success' | 'error' | null>(null)
- const [errorMessage, setErrorMessage] = useState('')
const [images, setImages] = useState([])
- const [imageError, setImageError] = useState(null)
const [isProcessing, setIsProcessing] = useState(false)
const [isDragging, setIsDragging] = useState(false)
@@ -93,8 +89,6 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
useEffect(() => {
if (open) {
setSubmitStatus(null)
- setErrorMessage('')
- setImageError(null)
setImages([])
setIsDragging(false)
setIsProcessing(false)
@@ -262,8 +256,6 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
*/
const processFiles = useCallback(
async (files: FileList | File[]) => {
- setImageError(null)
-
if (!files || files.length === 0) return
setIsProcessing(true)
@@ -275,16 +267,12 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
for (const file of Array.from(files)) {
// Validate file size
if (file.size > MAX_FILE_SIZE) {
- setImageError(`File ${file.name} is too large. Maximum size is 20MB.`)
hasError = true
continue
}
// Validate file type
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
- setImageError(
- `File ${file.name} has an unsupported format. Please use JPEG, PNG, WebP, or GIF.`
- )
hasError = true
continue
}
@@ -303,7 +291,6 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
}
} catch (error) {
logger.error('Error processing images:', { error })
- setImageError('An error occurred while processing images. Please try again.')
} finally {
setIsProcessing(false)
@@ -378,7 +365,6 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
async (data: FormValues) => {
setIsSubmitting(true)
setSubmitStatus(null)
- setErrorMessage('')
try {
// Prepare form data with images
@@ -413,7 +399,6 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
} catch (error) {
logger.error('Error submitting help request:', { error })
setSubmitStatus('error')
- setErrorMessage(error instanceof Error ? error.message : 'An unknown error occurred')
} finally {
setIsSubmitting(false)
}
@@ -430,213 +415,149 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
return (
-
- {/* Modal Header */}
-
-
- Help & Support
-
-
+
+ Help & Support
- {/* Modal Body */}
-
-
- {/* Scrollable Form Content */}
-
-
-
- {/* Request Type Field */}
-
-
- Request
-
-
setValue('type', value as FormValues['type'])}
- placeholder='Select a request type'
- editable={false}
- filterOptions={false}
- className={cn(
- errors.type && 'border-[var(--text-error)] dark:border-[var(--text-error)]'
- )}
- />
- {errors.type && (
-
- {errors.type.message}
-
- )}
-
-
- {/* Subject Field */}
-
-
- Subject
-
-
- {errors.subject && (
-
- {errors.subject.message}
-
- )}
-
-
- {/* Message Field */}
-
-
- Message
-
-
- {errors.message && (
-
- {errors.message.message}
-
- )}
-
-
- {/* Image Upload Section */}
-
-
- Attach Images (Optional)
-
-
fileInputRef.current?.click()}
- >
-
-
- {isDragging ? 'Drop images here!' : 'Drop images here or click to browse'}
-
-
- JPEG, PNG, WebP, GIF (max 20MB each)
-
-
- {imageError && (
-
- {imageError}
-
- )}
- {isProcessing && (
-
-
-
Processing images...
-
- )}
-
-
- {/* Image Preview Grid */}
- {images.length > 0 && (
-
-
- Uploaded Images
-
-
- {images.map((image, index) => (
-
-
-
- removeImage(index)}
- >
-
-
-
-
- {image.name}
-
-
- ))}
-
-
- )}
+
+
+
+
+
+
+ Request
+
+ setValue('type', value as FormValues['type'])}
+ placeholder='Select a request type'
+ editable={false}
+ filterOptions={false}
+ className={cn(errors.type && 'border-[var(--text-error)]')}
+ />
-
-
- {/* Fixed Footer with Actions */}
-
-
-
- Cancel
-
-
- {isSubmitting && }
- {isSubmitting
- ? 'Submitting...'
- : submitStatus === 'error'
- ? 'Error'
- : submitStatus === 'success'
- ? 'Success'
- : 'Submit'}
-
+
+
+ Subject
+
+
+
+
+
+
+ Message
+
+
+
+
+
+
+ Attach Images (Optional)
+
+
fileInputRef.current?.click()}
+ onDragEnter={handleDragEnter}
+ onDragOver={handleDragOver}
+ onDragLeave={handleDragLeave}
+ onDrop={handleDrop}
+ className={cn(
+ 'w-full justify-center border border-[var(--c-575757)] border-dashed',
+ {
+ 'border-[var(--brand-primary-hex)]': isDragging,
+ }
+ )}
+ >
+
+
+
+ {isDragging ? 'Drop images here' : 'Drop images here or click to browse'}
+
+ PNG, JPEG, WebP, GIF (max 20MB each)
+
+
+
+
+ {images.length > 0 && (
+
+
Uploaded Images
+
+ {images.map((image, index) => (
+