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 a151aef4d9..28de03e6cb 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 b9031f9bbe..ef4a664648 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 a5a173db62..b50ac6bd85 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 99%
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 270775c386..2682aa8981 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/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
@@ -188,7 +188,7 @@ function ShimmerOverlayText({
{actionVerb ? (
<>
{actionVerb}
- {remainder}
+ {remainder}
>
) : (
{text}
@@ -453,11 +453,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
@@ -869,7 +865,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-[#939393] text-sm'
/>
{renderPendingDetails()}
{showButtons && (
@@ -895,7 +891,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-[#939393] text-sm'
/>
{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 7220bfdc85..d6202f23d0 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 d270281c38..b4c9dc2499 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 96b5404fcd..07df6e8c4c 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 c18800c123..0000000000
--- 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 0000000000..96225fb3b2
--- /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/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 1bd5d72c26..0000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/auth-selector.tsx
+++ /dev/null
@@ -1,292 +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/env'
-import { cn, generatePassword } from '@/lib/utils'
-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 1ed663db4c..0000000000
--- 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 { createLogger } from '@/lib/logs/console/logger'
-import { getEmailDomain } from '@/lib/urls/utils'
-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 0000000000..002c956c15
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx
@@ -0,0 +1,870 @@
+'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-new/modal'
+import { Alert, AlertDescription, Skeleton } from '@/components/ui'
+import { getEnv, isTruthy } from '@/lib/env'
+import { createLogger } from '@/lib/logs/console/logger'
+import { getEmailDomain } from '@/lib/urls/utils'
+import { cn, generatePassword } from '@/lib/utils'
+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 0000000000..34a3694b5f
--- /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 0000000000..d7eda4084d
--- /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 3c9b675fbc..a7d78d47a7 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 9251bbfccb..0000000000
--- 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/utils'
-
-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 b9e0c09df4..0000000000
--- 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 { createLogger } from '@/lib/logs/console/logger'
-import { cn } from '@/lib/utils'
-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 ba0beba9cc..0000000000
--- 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 a5daf5d2c3..0000000000
--- 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 36e2b50d1d..0000000000
--- 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/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 0000000000..a9946f7d52
--- /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 0000000000..cd18d2ff10
--- /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/db-helpers'
+
+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 0000000000..04bb3bb18c
--- /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-new/modal'
+import { Skeleton } from '@/components/ui'
+import { createLogger } from '@/lib/logs/console/logger'
+import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/db-helpers'
+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 464c1dfe63..0000000000
--- 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 { getEmailDomain } from '@/lib/urls/utils'
-import { cn } from '@/lib/utils'
-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 4a361022c1..0000000000
--- 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/urls/utils'
-
-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 e56b7c7564..0000000000
--- 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-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/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(() => {
- 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 0000000000..4f628ccf47
--- /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-new/modal'
+import { Skeleton, TagInput } from '@/components/ui'
+import { useSession } from '@/lib/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 e9d00ebd42..da1aa1dfe2 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-new/modal'
import { getEnv } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/db-helpers'
import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/deployment-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)
-
- useEffect(() => {
- if (editingVersion !== null && inputRef.current) {
- inputRef.current.focus()
- inputRef.current.select()
- }
- }, [editingVersion])
+ 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)
+
+ 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 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)
- }
- } 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}`
+ const handlePromoteToLive = useCallback(
+ async (version: number) => {
+ if (!workflowId) return
+
+ // Optimistically update versions to show the new active version immediately
+ const previousVersions = [...versions]
+ setVersions((prev) =>
+ prev.map((v) => ({
+ ...v,
+ isActive: v.version === version,
+ }))
+ )
- if (editValue.trim() === currentName) {
- setEditingVersion(null)
- return
- }
+ try {
+ const response = await fetch(
+ `/api/workflows/${workflowId}/deployments/${version}/activate`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ }
+ )
- 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 (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData.error || 'Failed to promote version')
+ }
- if (res.ok) {
- await fetchVersions()
- setEditingVersion(null)
- } else {
- logger.error('Failed to rename 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
}
- } 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({
}
}
- return (
- <>
-
-
-
-
-
- Deploy Workflow
- {needsRedeployment && versions.length > 0 && versionToActivate === null && (
-
- {versions.find((v) => v.isActive)?.name ||
- `v${versions.find((v) => v.isActive)?.version}`}{' '}
- active
-
- )}
-
-
-
- Close
-
-
-
-
-
-
-
- setActiveTab('api')}
- >
- API
-
- setActiveTab('chat')}
- >
- Chat
-
- setActiveTab('versions')}
- >
- Versions
-
- setActiveTab('template')}
- >
- 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
-
-
-
- ) : (
-
- )}
-
- )}
+ 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()
+ }
+ }
+ }
- {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
-
-
-
- )}
- >
- )}
- >
- )}
+ 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()
+ }, [])
- {activeTab === 'chat' && (
-
+
+
+ Deploy Workflow
+
+ setActiveTab(value as TabView)}
+ className='flex min-h-0 flex-1 flex-col'
+ >
+
+ General
+ API
+ Chat
+ Template
+
+
+
+
+
+
+
+
+
+
+
+
+ {}}
+ />
+
+
+
+ {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/deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx
index e76ea18fe2..7c2253351b 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 321110c491..0000000000
--- 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 3c73581d74..0000000000
--- 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 1f0276ed7b..44f881c62e 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 1b5920553d..687a1df275 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/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 b44f7ccc21..03fca57046 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 67ca40be54..5e94f4b538 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 dc0e26a765..746598f442 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 3420d6a62b..a33a10c356 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/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 b2d5b11186..316baedcf1 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
@@ -1027,7 +1027,7 @@ 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...'
/>
)}
@@ -1107,7 +1107,7 @@ 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...'
/>
)}
@@ -1269,7 +1269,7 @@ try {
{isEditing ? (
setShowDeleteConfirm(true)}
>
@@ -1325,10 +1325,7 @@ try {
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.
@@ -1343,7 +1340,7 @@ try {
{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 12b098e3e2..6f64c0d39c 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
@@ -1602,7 +1602,7 @@ export function ToolInput({
{selectedTools.length === 0 ? (
-
+
Add Tool
@@ -1823,7 +1823,7 @@ export function ToolInput({
)}
-
+
{tool.title}
@@ -1952,7 +1952,7 @@ export function ToolInput({
e.stopPropagation()
handleRemoveTool(toolIndex)
}}
- className='text-[var(--text-tertiary)] transition-colors hover:text-[#EEEEEE]'
+ className='text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
aria-label='Remove tool'
>
@@ -2137,7 +2137,7 @@ export function ToolInput({
{/* Add Tool Button */}
-
+
Add Tool
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 8c5fbbf7fd..d1fd5125e4 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 48e07c76db..a91fbb1d48 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 80c125f233..40dad08c8d 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 73aa68867c..7046dcf00c 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 9b474d8022..8703c9587c 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) && (
) : (
{
@@ -364,7 +364,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)',
}}
/>
@@ -381,7 +381,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 e09e765b46..1ea9bf4e86 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 1b4269c3a2..fed01dc0e0 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
@@ -557,8 +555,8 @@ 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)]'
+ 'cursor-pointer hover:bg-[var(--border)] active:cursor-grabbing',
+ 'focus-visible:bg-[var(--border)] focus-visible:outline-none'
)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
@@ -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(--border)] active:cursor-grabbing',
+ 'focus-visible:bg-[var(--border)] focus-visible:outline-none'
)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
@@ -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 8f2ed8bea0..d288ba68c0 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={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 de23983551..c401fcaf26 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 0a11ad7b1a..a4c74fb8a3 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
@@ -827,7 +858,7 @@ export function Terminal() {
>
Error
{filters.statuses.has('error') && }
@@ -1067,7 +1098,7 @@ export function Terminal() {
)}
>
{BlockIcon && (
-
+
)}
{entry.blockName}
@@ -1086,12 +1117,16 @@ export function Terminal() {
{statusInfo.label}
@@ -1184,10 +1219,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 +1237,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 4dfc5129bf..9f761051b0 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
@@ -18,12 +18,23 @@ import {
import { Label } from '@/components/emcn/components/label/label'
import { Trash } from '@/components/emcn/icons/trash'
import { cn, validateName } from '@/lib/utils'
+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
@@ -41,7 +52,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
@@ -96,14 +107,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,
@@ -116,12 +127,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 81c44cfd63..67817373fc 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
@@ -1169,9 +1169,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.
@@ -1186,7 +1184,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 c7cb27ccf6..29e76e31e0 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 cfd941367d..f90ac2467a 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
@@ -23,7 +23,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,
@@ -400,7 +400,7 @@ const SubBlockRow = ({
{displayValue !== undefined && (
{displayValue}
@@ -426,15 +426,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)
@@ -852,7 +848,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
@@ -870,8 +866,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()
@@ -899,8 +895,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()
@@ -953,7 +949,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 dcd94d4296..bb5b972b70 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 51c1ae8a87..4292f8743c 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 7625dc88cf..7fd80df207 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 0000000000..c487cd8968
--- /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 54be332579..a9fbfcd06b 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 643e1d6a84..cc71da9574 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 08a3e17d27..33c6e72238 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 b3b7ab814c..c92f0e9679 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 8835e28a99..62e0c337c3 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 df82ed7543..310c4ed563 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
@@ -88,23 +88,21 @@ export function FooterNavigation() {
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)]'
+ active ? 'bg-[var(--border)]' : 'hover:bg-[var(--border)]'
)
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]',
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 172e1b5009..dc67cbb5c1 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-new/modal'
import { Label } from '@/components/ui/label'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
@@ -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
-
-
-
- {/* 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}
-
- )}
-
+
+ Help & Support
+
+
+
+
+
+
+
+ Request
+
+ setValue('type', value as FormValues['type'])}
+ placeholder='Select a request type'
+ editable={false}
+ filterOptions={false}
+ className={cn(errors.type && 'border-[var(--text-error)]')}
+ />
+
- {/* Subject Field */}
-
-
- Subject
-
-
- {errors.subject && (
-
- {errors.subject.message}
-
- )}
-
+
+
+ Subject
+
+
+
- {/* Message Field */}
-
-
- Message
-
-
- {errors.message && (
-
- {errors.message.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...
-
+
+
+ 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,
+ }
)}
-
-
- {/* Image Preview Grid */}
- {images.length > 0 && (
-
-
- Uploaded Images
-
-
- {images.map((image, index) => (
-
-
-
- removeImage(index)}
- >
-
-
-
-
- {image.name}
-
-
- ))}
-
+ >
+
+
+
+ {isDragging ? 'Drop images here' : 'Drop images here or click to browse'}
+
+ PNG, JPEG, WebP, GIF (max 20MB each)
- )}
+
-
-
- {/* Fixed Footer with Actions */}
-
-
-
- Cancel
-
-
- {isSubmitting && }
- {isSubmitting
- ? 'Submitting...'
- : submitStatus === 'error'
- ? 'Error'
- : submitStatus === 'success'
- ? 'Success'
- : 'Submit'}
-
+ {images.length > 0 && (
+
+
Uploaded Images
+
+ {images.map((image, index) => (
+
+
+
+ removeImage(index)}
+ >
+
+
+
+
{image.name}
+
+ ))}
+
+
+ )}
-
-
+
+
+
+
+ Cancel
+
+
+ {isSubmitting
+ ? 'Submitting...'
+ : submitStatus === 'error'
+ ? 'Error'
+ : submitStatus === 'success'
+ ? 'Success'
+ : 'Submit'}
+
+
+
)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/search-modal/search-modal.tsx
index 6129da68fc..1615ebf144 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/search-modal/search-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/search-modal/search-modal.tsx
@@ -501,14 +501,14 @@ export function SearchModal({
{/* Search input container */}
-
-
+
+
setSearchQuery(e.target.value)}
- className='w-full border-0 bg-transparent font-base text-[15px] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] focus:outline-none dark:text-[var(--text-primary)] dark:placeholder:text-[var(--text-secondary)]'
+ className='w-full border-0 bg-transparent font-base text-[15px] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] focus:outline-none'
autoFocus
/>
@@ -523,7 +523,7 @@ export function SearchModal({
return (
{/* Section header */}
-
+
{sectionTitles[type]}
@@ -545,10 +545,10 @@ export function SearchModal({
onClick={() => handleItemClick(item)}
onMouseDown={(e) => e.preventDefault()}
className={cn(
- 'group flex h-[28px] w-full items-center gap-[8px] rounded-[6px] bg-[var(--surface-4)]/60 px-[10px] text-left text-[15px] transition-all focus:outline-none dark:bg-[var(--surface-4)]/60',
+ 'group flex h-[28px] w-full items-center gap-[8px] rounded-[6px] bg-[var(--surface-4)]/60 px-[10px] text-left text-[15px] transition-all focus:outline-none',
isSelected
- ? 'bg-[var(--border)] shadow-sm dark:bg-[var(--border)]'
- : 'hover:bg-[var(--border)] dark:hover:bg-[var(--border)]'
+ ? 'bg-[var(--border)] shadow-sm'
+ : 'hover:bg-[var(--border)]'
)}
>
{/* Icon - different rendering for workflows vs others */}
@@ -574,7 +574,7 @@ export function SearchModal({
'transition-transform duration-100 group-hover:scale-110',
showColoredIcon
? '!h-[10px] !w-[10px] text-white'
- : 'h-[14px] w-[14px] text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)] dark:text-[var(--text-tertiary)] dark:group-hover:text-[var(--text-primary)]'
+ : 'h-[14px] w-[14px] text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
)}
/>
@@ -588,8 +588,8 @@ export function SearchModal({
className={cn(
'truncate font-medium',
isSelected
- ? '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)]'
)}
>
{item.name}
@@ -598,7 +598,7 @@ export function SearchModal({
{/* Shortcut */}
{item.shortcut && (
-
+
{item.shortcut}
)}
@@ -611,8 +611,8 @@ export function SearchModal({
})}
) : searchQuery ? (
-
-
+
+
No results found for "{searchQuery}"
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/account/account.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/account/account.tsx
deleted file mode 100644
index d2747bb063..0000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/account/account.tsx
+++ /dev/null
@@ -1,320 +0,0 @@
-'use client'
-
-import { useEffect, useRef, useState } from 'react'
-import { Camera } from 'lucide-react'
-import Image from 'next/image'
-import { useRouter } from 'next/navigation'
-import { Button } from '@/components/emcn'
-import { AgentIcon } from '@/components/icons'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import { signOut } from '@/lib/auth-client'
-import { useBrandConfig } from '@/lib/branding/branding'
-import { createLogger } from '@/lib/logs/console/logger'
-import { getBaseUrl } from '@/lib/urls/utils'
-import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/account/hooks/use-profile-picture-upload'
-import { useUpdateUserProfile, useUserProfile } from '@/hooks/queries/user-profile'
-import { clearUserData } from '@/stores'
-
-const logger = createLogger('Account')
-
-interface AccountProps {
- onOpenChange: (open: boolean) => void
-}
-
-export function Account(_props: AccountProps) {
- const router = useRouter()
- const brandConfig = useBrandConfig()
-
- // React Query hooks - with placeholderData to show cached data immediately
- const { data: profile } = useUserProfile()
- const updateProfile = useUpdateUserProfile()
-
- // Local UI state
- const [name, setName] = useState(profile?.name || '')
- const [isEditingName, setIsEditingName] = useState(false)
- const inputRef = useRef
(null)
-
- const [isResettingPassword, setIsResettingPassword] = useState(false)
- const [resetPasswordMessage, setResetPasswordMessage] = useState<{
- type: 'success' | 'error'
- text: string
- } | null>(null)
-
- const [uploadError, setUploadError] = useState(null)
-
- // Update local name state when profile data changes
- useEffect(() => {
- if (profile?.name) {
- setName(profile.name)
- }
- }, [profile?.name])
-
- const {
- previewUrl: profilePictureUrl,
- fileInputRef: profilePictureInputRef,
- handleThumbnailClick: handleProfilePictureClick,
- handleFileChange: handleProfilePictureChange,
- isUploading: isUploadingProfilePicture,
- } = useProfilePictureUpload({
- currentImage: profile?.image || null,
- onUpload: async (url) => {
- try {
- await updateProfile.mutateAsync({ image: url })
- setUploadError(null)
- } catch (error) {
- setUploadError(
- url ? 'Failed to update profile picture' : 'Failed to remove profile picture'
- )
- }
- },
- onError: (error) => {
- setUploadError(error)
- setTimeout(() => setUploadError(null), 5000)
- },
- })
-
- useEffect(() => {
- if (isEditingName && inputRef.current) {
- inputRef.current.focus()
- inputRef.current.select()
- }
- }, [isEditingName])
-
- const handleUpdateName = async () => {
- const trimmedName = name.trim()
-
- if (!trimmedName) {
- return
- }
-
- if (trimmedName === profile?.name) {
- setIsEditingName(false)
- return
- }
-
- try {
- await updateProfile.mutateAsync({ name: trimmedName })
- setIsEditingName(false)
- } catch (error) {
- logger.error('Error updating name:', error)
- setName(profile?.name || '')
- }
- }
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === 'Enter') {
- e.preventDefault()
- handleUpdateName()
- } else if (e.key === 'Escape') {
- e.preventDefault()
- handleCancelEdit()
- }
- }
-
- const handleCancelEdit = () => {
- setIsEditingName(false)
- setName(profile?.name || '')
- }
-
- const handleInputBlur = () => {
- handleUpdateName()
- }
-
- const handleSignOut = async () => {
- try {
- await Promise.all([signOut(), clearUserData()])
- router.push('/login?fromLogout=true')
- } catch (error) {
- logger.error('Error signing out:', { error })
- router.push('/login?fromLogout=true')
- }
- }
-
- const handleResetPassword = async () => {
- if (!profile?.email) return
-
- setIsResettingPassword(true)
- setResetPasswordMessage(null)
-
- try {
- const response = await fetch('/api/auth/forget-password', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- email: profile.email,
- redirectTo: `${getBaseUrl()}/reset-password`,
- }),
- })
-
- if (!response.ok) {
- const error = await response.json()
- throw new Error(error.message || 'Failed to send reset password email')
- }
-
- setResetPasswordMessage({
- type: 'success',
- text: 'email sent',
- })
-
- setTimeout(() => {
- setResetPasswordMessage(null)
- }, 5000)
- } catch (error) {
- logger.error('Error resetting password:', error)
- setResetPasswordMessage({
- type: 'error',
- text: 'error',
- })
-
- setTimeout(() => {
- setResetPasswordMessage(null)
- }, 5000)
- } finally {
- setIsResettingPassword(false)
- }
- }
-
- return (
-
-
- {/* User Info Section */}
-
- {/* Profile Picture Upload */}
-
-
- {(() => {
- const imageUrl = profilePictureUrl || profile?.image || brandConfig.logoUrl
- return imageUrl ? (
-
- ) : (
-
- )
- })()}
-
- {/* Upload overlay */}
-
- {isUploadingProfilePicture ? (
-
- ) : (
-
- )}
-
-
-
- {/* Hidden file input */}
-
-
-
- {/* User Details */}
-
-
{profile?.name || ''}
-
{profile?.email || ''}
- {uploadError && (
-
- {uploadError}
-
- )}
-
-
-
- {/* Name Field */}
-
-
- Name
-
- {isEditingName ? (
-
setName(e.target.value)}
- onKeyDown={handleKeyDown}
- onBlur={handleInputBlur}
- className='min-w-0 flex-1 border-0 bg-transparent p-0 text-base outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
- maxLength={100}
- disabled={updateProfile.isPending}
- autoComplete='off'
- autoCorrect='off'
- autoCapitalize='off'
- spellCheck='false'
- />
- ) : (
-
- {profile?.name || ''}
- setIsEditingName(true)}
- >
- update
- Update name
-
-
- )}
-
-
- {/* Email Field - Read Only */}
-
-
Email
-
{profile?.email || ''}
-
-
- {/* Password Field */}
-
-
Password
-
- ••••••••
-
- {isResettingPassword
- ? 'sending...'
- : resetPasswordMessage
- ? resetPasswordMessage.text
- : 'reset'}
- Reset password
-
-
-
-
- {/* Sign Out Button */}
-
-
- Sign Out
-
-
-
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/api-keys/api-keys.tsx
index 0ace598589..78124d8351 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/api-keys/api-keys.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/api-keys/api-keys.tsx
@@ -224,7 +224,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
return (
{/* Fixed Header */}
-
+
{/* Search Input */}
{isLoading ? (
@@ -242,8 +242,8 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
{/* Scrollable Content */}
-
-
+
+
{isLoading ? (
@@ -428,7 +428,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
{/* Footer */}
-
+
{isLoading ? (
) : (
@@ -595,9 +595,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
Delete API key?
Deleting this API key will immediately revoke access for any integrations using it.{' '}
-
- This action cannot be undone.
-
+ This action cannot be undone.
@@ -613,7 +611,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
Cancel
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/copilot/copilot.tsx
index a6f1c42922..1e2afceb02 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/copilot/copilot.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/copilot/copilot.tsx
@@ -177,8 +177,8 @@ export function Copilot() {
return (
{/* Scrollable Content */}
-
-
+
+
{keys.length === 0 ? (
Click "Create Key" below to get started
@@ -219,7 +219,7 @@ export function Copilot() {
{/* Footer */}
-
+
{/* Scrollable Content */}
-
-
+
+
{/* Profile Type - only show if user has organizations */}
{organizations.length > 0 && (
+
{saveError}
@@ -423,7 +423,7 @@ export function CreatorProfile() {
{/* Footer */}
-
+
Set up your creator profile for publishing templates
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/credentials/credentials.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/credentials/credentials.tsx
index 25c4c61df4..e5d41d18fc 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/credentials/credentials.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/credentials/credentials.tsx
@@ -179,7 +179,7 @@ export function Credentials({ onOpenChange, registerCloseHandler }: CredentialsP
return (
{/* Search Input */}
-
+
{/* Scrollable Content */}
-
-
+
+
{/* Success message */}
{authSuccess && (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/custom-tools/custom-tools.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/custom-tools/custom-tools.tsx
index c05f1b947b..09f94f7668 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/custom-tools/custom-tools.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/custom-tools/custom-tools.tsx
@@ -110,7 +110,7 @@ export function CustomTools() {
return (
{/* Fixed Header with Search */}
-
+
{/* Search Input */}
{isLoading ? (
@@ -128,8 +128,8 @@ export function CustomTools() {
{/* Scrollable Content */}
-
-
+
+
{isLoading ? (
@@ -202,7 +202,7 @@ export function CustomTools() {
{/* Footer */}
-
+
{isLoading ? (
<>
@@ -259,9 +259,7 @@ export function CustomTools() {
Deleting "{toolToDelete?.name}" will permanently remove this custom tool from your
workspace.{' '}
-
- This action cannot be undone.
-
+ This action cannot be undone.
@@ -277,7 +275,7 @@ export function CustomTools() {
Cancel
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/environment/environment.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/environment/environment.tsx
index cf90664290..56247a0d90 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/environment/environment.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/environment/environment.tsx
@@ -519,7 +519,7 @@ export function EnvironmentVariables({
readOnly
/>
{/* Fixed Header */}
-
+
{/* Search Input */}
{isLoading ? (
@@ -543,8 +543,8 @@ export function EnvironmentVariables({
{/* Scrollable Content */}
-
-
+
+
{isLoading ? (
<>
{/* Show 3 skeleton rows */}
@@ -695,7 +695,7 @@ export function EnvironmentVariables({
{/* Footer */}
-
+
{isLoading ? (
<>
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/files/files.tsx
index 9c8e8f1152..3d49fc15c0 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/files/files.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/files/files.tsx
@@ -208,7 +208,7 @@ export function Files() {
return (
{/* Header: search left, file count + Upload right */}
-
+
+
{uploadError}
@@ -280,7 +280,7 @@ export function Files() {
)}
{/* Files Table */}
-
+
{files.length === 0 ? (
No files uploaded yet
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/general/general.tsx
index 658df0cdc9..03101a6790 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/general/general.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/general/general.tsx
@@ -1,48 +1,83 @@
-import { useEffect, useState } from 'react'
-import { Info } from 'lucide-react'
-import { Tooltip } from '@/components/emcn'
-import { Button } from '@/components/ui/button'
-import { Label } from '@/components/ui/label'
-// COMMENTED OUT: Theme switching disabled - dark mode is forced for workspace
-// import {
-// Select,
-// SelectContent,
-// SelectItem,
-// SelectTrigger,
-// SelectValue,
-// } from '@/components/ui/select'
-import { Switch } from '@/components/ui/switch'
-import { useSession } from '@/lib/auth-client'
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import { Camera, Check, Pencil } from 'lucide-react'
+import Image from 'next/image'
+import { useRouter } from 'next/navigation'
+import { Button, Combobox, Label, Switch } from '@/components/emcn'
+import {
+ Modal,
+ ModalBody,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+} from '@/components/emcn/components/modal-new/modal'
+import { Input, Skeleton } from '@/components/ui'
+import { signOut, useSession } from '@/lib/auth-client'
+import { useBrandConfig } from '@/lib/branding/branding'
import { getEnv, isTruthy } from '@/lib/env'
+import { createLogger } from '@/lib/logs/console/logger'
+import { getBaseUrl } from '@/lib/urls/utils'
+import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/hooks/use-profile-picture-upload'
import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
+import { useUpdateUserProfile, useUserProfile } from '@/hooks/queries/user-profile'
+import { clearUserData } from '@/stores'
+
+const logger = createLogger('General')
+
+/**
+ * Extracts initials from a user's name.
+ * @param name - The user's full name
+ * @returns Up to 2 characters: first letters of first and last name, or just the first letter
+ */
+function getInitials(name: string | undefined | null): string {
+ if (!name?.trim()) return ''
+ const parts = name.trim().split(' ')
+ if (parts.length >= 2) {
+ return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase()
+ }
+ return parts[0][0].toUpperCase()
+}
-const TOOLTIPS = {
- autoConnect: 'Automatically connect nodes.',
- autoPan: 'Automatically pan to active blocks during workflow execution.',
- consoleExpandedByDefault:
- 'Show console entries expanded by default. When disabled, entries will be collapsed by default.',
- floatingControls:
- 'Show floating controls for zoom, undo, and redo at the bottom of the workflow canvas.',
- trainingControls:
- 'Show training controls for recording workflow edits to build copilot training datasets.',
- superUserMode:
- 'Toggle super user mode UI. When enabled, you can see and approve pending templates. Your super user status in the database remains unchanged.',
- errorNotifications:
- 'Show error notifications when blocks fail. When disabled, errors will only appear in the console.',
+interface GeneralProps {
+ onOpenChange?: (open: boolean) => void
}
-export function General() {
+export function General({ onOpenChange }: GeneralProps) {
+ const router = useRouter()
+ const brandConfig = useBrandConfig()
const { data: session } = useSession()
- const [isSuperUser, setIsSuperUser] = useState(false)
- const [loadingSuperUser, setLoadingSuperUser] = useState(true)
- // React Query hooks - with placeholderData to show cached data immediately
- const { data: settings, isLoading } = useGeneralSettings()
+ const { data: profile, isLoading: isProfileLoading } = useUserProfile()
+ const updateProfile = useUpdateUserProfile()
+
+ const { data: settings, isLoading: isSettingsLoading } = useGeneralSettings()
const updateSetting = useUpdateGeneralSetting()
+ const isLoading = isProfileLoading || isSettingsLoading
+
const isTrainingEnabled = isTruthy(getEnv('NEXT_PUBLIC_COPILOT_TRAINING_ENABLED'))
- // Fetch super user status from database
+ const [isSuperUser, setIsSuperUser] = useState(false)
+ const [loadingSuperUser, setLoadingSuperUser] = useState(true)
+
+ const [name, setName] = useState(profile?.name || '')
+ const [isEditingName, setIsEditingName] = useState(false)
+ const inputRef = useRef
(null)
+
+ const [isResettingPassword, setIsResettingPassword] = useState(false)
+ const [showResetPasswordModal, setShowResetPasswordModal] = useState(false)
+ const [resetPasswordSuccess, setResetPasswordSuccess] = useState(false)
+ const [resetPasswordError, setResetPasswordError] = useState(null)
+
+ const [uploadError, setUploadError] = useState(null)
+
+ useEffect(() => {
+ if (profile?.name) {
+ setName(profile.name)
+ }
+ }, [profile?.name])
+
useEffect(() => {
const fetchSuperUserStatus = async () => {
try {
@@ -52,7 +87,7 @@ export function General() {
setIsSuperUser(data.isSuperUser)
}
} catch (error) {
- console.error('Failed to fetch super user status:', error)
+ logger.error('Failed to fetch super user status:', error)
} finally {
setLoadingSuperUser(false)
}
@@ -63,47 +98,135 @@ export function General() {
}
}, [session?.user?.id])
- const handleSuperUserModeToggle = async (checked: boolean) => {
- if (checked !== settings?.superUserModeEnabled && !updateSetting.isPending) {
- await updateSetting.mutateAsync({ key: 'superUserModeEnabled', value: checked })
+ const {
+ previewUrl: profilePictureUrl,
+ fileInputRef: profilePictureInputRef,
+ handleThumbnailClick: handleProfilePictureClick,
+ handleFileChange: handleProfilePictureChange,
+ isUploading: isUploadingProfilePicture,
+ } = useProfilePictureUpload({
+ currentImage: profile?.image || null,
+ onUpload: (url: string | null) => {
+ updateProfile
+ .mutateAsync({ image: url })
+ .then(() => {
+ setUploadError(null)
+ })
+ .catch(() => {
+ setUploadError(
+ url ? 'Failed to update profile picture' : 'Failed to remove profile picture'
+ )
+ })
+ },
+ onError: (error: string) => {
+ setUploadError(error)
+ setTimeout(() => setUploadError(null), 5000)
+ },
+ })
+
+ useEffect(() => {
+ if (isEditingName && inputRef.current) {
+ inputRef.current.focus()
+ inputRef.current.select()
}
- }
+ }, [isEditingName])
- // COMMENTED OUT: Theme switching disabled - dark mode is forced for workspace
- // // Sync theme from store to next-themes when theme changes
- // useEffect(() => {
- // if (!isLoading && theme) {
- // // Ensure next-themes is in sync with our store
- // const { syncThemeToNextThemes } = require('@/lib/theme-sync')
- // syncThemeToNextThemes(theme)
- // }
- // }, [theme, isLoading])
-
- const handleThemeChange = async (value: 'system' | 'light' | 'dark') => {
- await updateSetting.mutateAsync({ key: 'theme', value })
+ const handleUpdateName = async () => {
+ const trimmedName = name.trim()
+
+ if (!trimmedName) {
+ return
+ }
+
+ if (trimmedName === profile?.name) {
+ setIsEditingName(false)
+ return
+ }
+
+ try {
+ await updateProfile.mutateAsync({ name: trimmedName })
+ setIsEditingName(false)
+ } catch (error) {
+ logger.error('Error updating name:', error)
+ setName(profile?.name || '')
+ }
}
- const handleAutoConnectChange = async (checked: boolean) => {
- if (checked !== settings?.autoConnect && !updateSetting.isPending) {
- await updateSetting.mutateAsync({ key: 'autoConnect', value: checked })
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ handleUpdateName()
+ } else if (e.key === 'Escape') {
+ e.preventDefault()
+ handleCancelEdit()
}
}
- const handleAutoPanChange = async (checked: boolean) => {
- if (checked !== settings?.autoPan && !updateSetting.isPending) {
- await updateSetting.mutateAsync({ key: 'autoPan', value: checked })
+ const handleCancelEdit = () => {
+ setIsEditingName(false)
+ setName(profile?.name || '')
+ }
+
+ const handleInputBlur = () => {
+ handleUpdateName()
+ }
+
+ const handleSignOut = async () => {
+ try {
+ await Promise.all([signOut(), clearUserData()])
+ router.push('/login?fromLogout=true')
+ } catch (error) {
+ logger.error('Error signing out:', { error })
+ router.push('/login?fromLogout=true')
}
}
- const handleConsoleExpandedByDefaultChange = async (checked: boolean) => {
- if (checked !== settings?.consoleExpandedByDefault && !updateSetting.isPending) {
- await updateSetting.mutateAsync({ key: 'consoleExpandedByDefault', value: checked })
+ const handleResetPasswordConfirm = async () => {
+ if (!profile?.email) return
+
+ setIsResettingPassword(true)
+ setResetPasswordError(null)
+
+ try {
+ const response = await fetch('/api/auth/forget-password', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ email: profile.email,
+ redirectTo: `${getBaseUrl()}/reset-password`,
+ }),
+ })
+
+ if (!response.ok) {
+ const error = await response.json()
+ throw new Error(error.message || 'Failed to send reset password email')
+ }
+
+ setResetPasswordSuccess(true)
+
+ setTimeout(() => {
+ setShowResetPasswordModal(false)
+ setResetPasswordSuccess(false)
+ }, 1500)
+ } catch (error) {
+ logger.error('Error resetting password:', error)
+ setResetPasswordError('Failed to send email')
+
+ setTimeout(() => {
+ setResetPasswordError(null)
+ }, 5000)
+ } finally {
+ setIsResettingPassword(false)
}
}
- const handleFloatingControlsChange = async (checked: boolean) => {
- if (checked !== settings?.showFloatingControls && !updateSetting.isPending) {
- await updateSetting.mutateAsync({ key: 'showFloatingControls', value: checked })
+ const handleThemeChange = async (value: string) => {
+ await updateSetting.mutateAsync({ key: 'theme', value: value as 'system' | 'light' | 'dark' })
+ }
+
+ const handleAutoConnectChange = async (checked: boolean) => {
+ if (checked !== settings?.autoConnect && !updateSetting.isPending) {
+ await updateSetting.mutateAsync({ key: 'autoConnect', value: checked })
}
}
@@ -119,232 +242,312 @@ export function General() {
}
}
+ const handleSuperUserModeToggle = async (checked: boolean) => {
+ if (checked !== settings?.superUserModeEnabled && !updateSetting.isPending) {
+ await updateSetting.mutateAsync({ key: 'superUserModeEnabled', value: checked })
+ }
+ }
+
+ const handleTelemetryToggle = async (checked: boolean) => {
+ if (checked !== settings?.telemetryEnabled && !updateSetting.isPending) {
+ await updateSetting.mutateAsync({ key: 'telemetryEnabled', value: checked })
+
+ if (checked) {
+ if (typeof window !== 'undefined') {
+ fetch('/api/telemetry', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ category: 'consent',
+ action: 'enable_from_settings',
+ timestamp: new Date().toISOString(),
+ }),
+ }).catch(() => {})
+ }
+ }
+ }
+ }
+
+ const imageUrl = profilePictureUrl || profile?.image || brandConfig.logoUrl
+
+ if (isLoading) {
+ return
+ }
+
return (
-
-
- {/* COMMENTED OUT: Theme switching disabled - dark mode is forced for workspace */}
- {/*
-
-
- Theme
-
-
-
+ {/* User Info Section */}
+
+
+
-
-
-
-
-
- System
-
-
- Light
-
-
- Dark
-
-
-
-
*/}
-
-
-
-
- Auto-connect on drop
-
-
-
+ {(() => {
+ if (imageUrl) {
+ return (
+
+ )
+ }
+ return (
+
+ {getInitials(profile?.name) || ''}
+
+ )
+ })()}
+
+ {isUploadingProfilePicture ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {isEditingName ? (
+ <>
+
setName(e.target.value)}
+ onKeyDown={handleKeyDown}
+ onBlur={handleInputBlur}
+ className='w-auto border-0 bg-transparent p-0 font-medium text-[14px] outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
+ size={Math.max(name.length, 1)}
+ maxLength={100}
+ disabled={updateProfile.isPending}
+ autoComplete='off'
+ autoCorrect='off'
+ autoCapitalize='off'
+ spellCheck='false'
+ />
-
+
-
-
- {TOOLTIPS.autoConnect}
-
-
-
-
-
-
-
-
-
- Console expanded by default
-
-
-
+ >
+ ) : (
+ <>
+ {profile?.name || ''}
setIsEditingName(true)}
+ aria-label='Edit name'
>
-
+
-
-
- {TOOLTIPS.consoleExpandedByDefault}
-
-
+ >
+ )}
-
{profile?.email || ''}
+
+
+ {uploadError &&
{uploadError}
}
+
+
- {/* TODO: Add floating controls back when we implement the new UI for it */}
- {/*
-
-
- Floating controls
-
-
-
-
-
-
-
-
- {TOOLTIPS.floatingControls}
-
-
-
+
+ Auto-connect on drop
+
+
+
+
+ Run error notifications
+
+
+
+
+ Allow anonymous telemetry
+
+
+
+
+ We use OpenTelemetry to collect anonymous usage data to improve Sim. All data is collected
+ in accordance with our privacy policy, and you can opt-out at any time.
+
+
+ {isTrainingEnabled && (
+
+ Training controls
-
*/}
+
+ )}
+ {!loadingSuperUser && isSuperUser && (
-
-
- Error notifications
-
-
-
-
-
-
-
-
- {TOOLTIPS.errorNotifications}
-
-
-
+
Super admin mode
+ )}
- {isTrainingEnabled && (
-
-
-
- Training controls
-
-
-
-
-
-
-
-
- {TOOLTIPS.trainingControls}
-
-
-
-
-
- )}
-
- {/* Super User Mode Toggle - Only visible to super users */}
- {!loadingSuperUser && isSuperUser && (
-
-
-
- Super User Mode
-
-
-
-
-
-
-
-
- {TOOLTIPS.superUserMode}
-
-
-
-
+
+ Sign out
+ setShowResetPasswordModal(true)}>Reset password
+
+
+ {/* Password Reset Confirmation Modal */}
+
+
+ Reset Password
+
+
+ A password reset link will be sent to{' '}
+ {profile?.email} .
+ Click the link in the email to create a new password.
+
+ {resetPasswordError && (
+ {resetPasswordError}
+ )}
+
+
+ setShowResetPasswordModal(false)}
+ disabled={isResettingPassword || resetPasswordSuccess}
+ >
+ Cancel
+
+
+ {isResettingPassword
+ ? 'Sending...'
+ : resetPasswordSuccess
+ ? 'Sent'
+ : 'Send Reset Email'}
+
+
+
+
+
+ )
+}
+
+/**
+ * Skeleton component for general settings loading state.
+ * Matches the exact layout structure of the General component.
+ */
+function GeneralSkeleton() {
+ return (
+
+ {/* User Info Section */}
+
+
+ {/* Theme row */}
+
+
+
+
+
+ {/* Auto-connect row */}
+
+
+
+
+
+ {/* Error notifications row */}
+
+
+
+
+
+ {/* Telemetry row */}
+
+
+
+
+
+ {/* Telemetry description */}
+
+
+
+
+
+ {/* Action buttons */}
+
+
+
)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/index.ts
index e654e5bd30..dd8a868fd5 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/index.ts
@@ -1,4 +1,3 @@
-export { Account } from './account/account'
export { ApiKeys } from './api-keys/api-keys'
export { Copilot } from './copilot/copilot'
export { Credentials } from './credentials/credentials'
@@ -7,8 +6,6 @@ export { EnvironmentVariables } from './environment/environment'
export { Files as FileUploads } from './files/files'
export { General } from './general/general'
export { MCP } from './mcp/mcp'
-export { Privacy } from './privacy/privacy'
-export { SettingsNavigation } from './settings-navigation/settings-navigation'
export { SSO } from './sso/sso'
export { Subscription } from './subscription/subscription'
export { TeamManagement } from './team-management/team-management'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/mcp/mcp.tsx
index 8b45d2050b..186f350e23 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/mcp/mcp.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/mcp/mcp.tsx
@@ -247,7 +247,7 @@ export function MCP() {
return (
{/* Fixed Header with Search */}
-
+
{/* Search Input */}
{serversLoading ? (
@@ -265,8 +265,8 @@ export function MCP() {
{/* Scrollable Content */}
-
-
+
+
{/* Server List */}
{toolsError || serversError ? (
@@ -442,7 +442,7 @@ export function MCP() {
{/* Footer */}
-
+
{serversLoading ? (
<>
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/privacy/privacy.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/privacy/privacy.tsx
deleted file mode 100644
index 003c683795..0000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/privacy/privacy.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-'use client'
-
-import { Info } from 'lucide-react'
-import { Tooltip } from '@/components/emcn'
-import { Button } from '@/components/ui/button'
-import { Label } from '@/components/ui/label'
-import { Switch } from '@/components/ui/switch'
-import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
-
-const TOOLTIPS = {
- telemetry:
- 'We collect anonymous data about feature usage, performance, and errors to improve the application.',
-}
-
-export function Privacy() {
- // React Query hooks - with placeholderData to show cached data immediately
- const { data: settings } = useGeneralSettings()
- const updateSetting = useUpdateGeneralSetting()
-
- const handleTelemetryToggle = async (checked: boolean) => {
- if (checked !== settings?.telemetryEnabled && !updateSetting.isPending) {
- await updateSetting.mutateAsync({ key: 'telemetryEnabled', value: checked })
-
- // Send telemetry event when enabling
- if (checked) {
- if (typeof window !== 'undefined') {
- fetch('/api/telemetry', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- category: 'consent',
- action: 'enable_from_settings',
- timestamp: new Date().toISOString(),
- }),
- }).catch(() => {
- // Silently fail - this is just telemetry
- })
- }
- }
- }
- }
-
- return (
-