diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css
index 254534f2891..ddb0d181485 100644
--- a/apps/sim/app/_styles/globals.css
+++ b/apps/sim/app/_styles/globals.css
@@ -677,17 +677,26 @@ input[type="search"]::-ms-clear {
* Panel tab visibility and styling to prevent hydration flash
*/
html[data-panel-active-tab="copilot"] .panel-container [data-tab-content="toolbar"],
- html[data-panel-active-tab="copilot"] .panel-container [data-tab-content="editor"] {
+ html[data-panel-active-tab="copilot"] .panel-container [data-tab-content="editor"],
+ html[data-panel-active-tab="copilot"] .panel-container [data-tab-content="logs"] {
display: none !important;
}
html[data-panel-active-tab="toolbar"] .panel-container [data-tab-content="copilot"],
- html[data-panel-active-tab="toolbar"] .panel-container [data-tab-content="editor"] {
+ html[data-panel-active-tab="toolbar"] .panel-container [data-tab-content="editor"],
+ html[data-panel-active-tab="toolbar"] .panel-container [data-tab-content="logs"] {
display: none !important;
}
html[data-panel-active-tab="editor"] .panel-container [data-tab-content="copilot"],
- html[data-panel-active-tab="editor"] .panel-container [data-tab-content="toolbar"] {
+ html[data-panel-active-tab="editor"] .panel-container [data-tab-content="toolbar"],
+ html[data-panel-active-tab="editor"] .panel-container [data-tab-content="logs"] {
+ display: none !important;
+ }
+
+ html[data-panel-active-tab="logs"] .panel-container [data-tab-content="copilot"],
+ html[data-panel-active-tab="logs"] .panel-container [data-tab-content="toolbar"],
+ html[data-panel-active-tab="logs"] .panel-container [data-tab-content="editor"] {
display: none !important;
}
@@ -696,7 +705,8 @@ input[type="search"]::-ms-clear {
color: var(--text-primary) !important;
}
html[data-panel-active-tab="copilot"] .panel-container [data-tab-button="toolbar"],
- html[data-panel-active-tab="copilot"] .panel-container [data-tab-button="editor"] {
+ html[data-panel-active-tab="copilot"] .panel-container [data-tab-button="editor"],
+ html[data-panel-active-tab="copilot"] .panel-container [data-tab-button="logs"] {
background-color: transparent !important;
color: var(--text-tertiary) !important;
}
@@ -706,7 +716,8 @@ input[type="search"]::-ms-clear {
color: var(--text-primary) !important;
}
html[data-panel-active-tab="toolbar"] .panel-container [data-tab-button="copilot"],
- html[data-panel-active-tab="toolbar"] .panel-container [data-tab-button="editor"] {
+ html[data-panel-active-tab="toolbar"] .panel-container [data-tab-button="editor"],
+ html[data-panel-active-tab="toolbar"] .panel-container [data-tab-button="logs"] {
background-color: transparent !important;
color: var(--text-tertiary) !important;
}
@@ -716,7 +727,19 @@ input[type="search"]::-ms-clear {
color: var(--text-primary) !important;
}
html[data-panel-active-tab="editor"] .panel-container [data-tab-button="copilot"],
- html[data-panel-active-tab="editor"] .panel-container [data-tab-button="toolbar"] {
+ html[data-panel-active-tab="editor"] .panel-container [data-tab-button="toolbar"],
+ html[data-panel-active-tab="editor"] .panel-container [data-tab-button="logs"] {
+ background-color: transparent !important;
+ color: var(--text-tertiary) !important;
+ }
+
+ html[data-panel-active-tab="logs"] .panel-container [data-tab-button="logs"] {
+ background-color: var(--border-1) !important;
+ color: var(--text-primary) !important;
+ }
+ html[data-panel-active-tab="logs"] .panel-container [data-tab-button="copilot"],
+ html[data-panel-active-tab="logs"] .panel-container [data-tab-button="toolbar"],
+ html[data-panel-active-tab="logs"] .panel-container [data-tab-button="editor"] {
background-color: transparent !important;
color: var(--text-tertiary) !important;
}
@@ -968,3 +991,7 @@ input[type="search"]::-ms-clear {
.react-flow__node[data-parent-node-id] .react-flow__handle {
z-index: 30;
}
+
+.react-flow__panel {
+ margin: 0 !important;
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx
index 21ae97ca049..639a26e7bb8 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
-import { Check, Clipboard, Key, Search } from 'lucide-react'
+import { Check, Clipboard, Info, Key, Search, Shield, UserPlus } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import {
Avatar,
@@ -1188,44 +1188,78 @@ export function CredentialsManager() {
)}
-
-
Members ({activeMembers.length})
+
+ {/* Header */}
+
+
+
+
+
+
+
+ Access Control
+
+
+ {activeMembers.length} {activeMembers.length === 1 ? 'member' : 'members'}
+
+
+
+ Only workspace members listed below can view and use this secret in their
+ workflows. Admins can manage access; members can only use the secret.
+
+
+
+ {/* Member list */}
{membersLoading ? (
-
-
-
+
) : (
-
- {activeMembers.map((member) => (
+
+ {activeMembers.map((member, index) => (
0 ? 'border-[var(--border)] border-t' : ''
+ }`}
>
-
-
-
- {(member.userName || member.userEmail || '?').charAt(0).toUpperCase()}
-
-
-
-
- {member.userName || member.userEmail || member.userId}
-
-
- {member.userEmail || member.userId}
-
-
+
+
+ {(member.userName || member.userEmail || '?').charAt(0).toUpperCase()}
+
+
+
+
+ {member.userName || member.userEmail || member.userId}
+
+
+ {member.userEmail || member.userId}
+
{isSelectedAdmin ? (
- <>
+
({
value: option.value,
@@ -1250,55 +1284,85 @@ export function CredentialsManager() {
variant='ghost'
onClick={() => handleRemoveMember(member.userId)}
disabled={member.role === 'admin' && adminMemberCount <= 1}
- className='w-full justify-end'
+ className='h-7 px-2 text-[var(--text-tertiary)] text-caption hover-hover:text-[var(--text-error)]'
>
Remove
- >
+
) : (
- <>
-
{member.role}
-
- >
+
+ {member.role === 'admin' ? 'Admin' : 'Member'}
+
)}
))}
+
+ {/* Add member row */}
{isSelectedAdmin && (
-
-
option.value === memberUserId)
- ?.label || ''
- }
- selectedValue={memberUserId}
- onChange={setMemberUserId}
- placeholder='Add member...'
- searchable
- searchPlaceholder='Search members...'
- size='sm'
- />
- ({
- value: option.value,
- label: option.label,
- }))}
- value={
- ROLE_OPTIONS.find((option) => option.value === memberRole)?.label || ''
- }
- selectedValue={memberRole}
- onChange={(value) => setMemberRole(value as WorkspaceCredentialRole)}
- placeholder='Role'
- size='sm'
- />
-
- Add
-
+
+
+
+
+ Grant access to a workspace member
+
+
+
+
+ option.value === memberUserId)
+ ?.label || ''
+ }
+ selectedValue={memberUserId}
+ onChange={setMemberUserId}
+ placeholder='Select workspace member...'
+ searchable
+ searchPlaceholder='Search workspace members...'
+ emptyMessage='No workspace members available. Invite members to the workspace first.'
+ size='sm'
+ />
+
+
+ ({
+ value: option.value,
+ label: option.label,
+ }))}
+ value={
+ ROLE_OPTIONS.find((option) => option.value === memberRole)?.label ||
+ ''
+ }
+ selectedValue={memberRole}
+ onChange={(value) => setMemberRole(value as WorkspaceCredentialRole)}
+ placeholder='Role'
+ size='sm'
+ />
+
+
+ {upsertMember.isPending ? 'Adding...' : 'Add'}
+
+
+
+
+ Only members of this workspace appear here. To add someone new, invite
+ them to the workspace first.
+
+
+ )}
+
+ {/* Non-admin notice */}
+ {!isSelectedAdmin && (
+
+
+
+ Only admins of this secret can manage access control.
+
)}
@@ -1307,7 +1371,7 @@ export function CredentialsManager() {
-
+
Back
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-input/copilot-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-input/copilot-input.tsx
new file mode 100644
index 00000000000..d7c37a85293
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-input/copilot-input.tsx
@@ -0,0 +1,91 @@
+'use client'
+
+import { memo, useCallback, useRef, useState } from 'react'
+import { ArrowUp, Bot } from 'lucide-react'
+import { Button, Tooltip } from '@/components/emcn'
+import { cn } from '@/lib/core/utils/cn'
+import { usePanelStore } from '@/stores/panel'
+
+const SEND_BUTTON_BASE = 'h-[28px] w-[28px] rounded-full border-0 p-0 transition-colors'
+const SEND_BUTTON_ACTIVE =
+ 'bg-[var(--surface-inverted)] hover-hover:bg-[var(--surface-inverted-hover)]'
+const SEND_BUTTON_DISABLED = 'bg-[var(--surface-6)]'
+
+export const CopilotInput = memo(function CopilotInput() {
+ const isPanelOpen = usePanelStore((s) => s.isPanelOpen)
+ const setPendingCopilotMessage = usePanelStore((s) => s.setPendingCopilotMessage)
+
+ const [value, setValue] = useState('')
+ const inputRef = useRef(null)
+
+ const canSubmit = value.trim().length > 0
+
+ const handleSubmit = useCallback(() => {
+ const trimmed = value.trim()
+ if (!trimmed) return
+ setPendingCopilotMessage(trimmed)
+ setValue('')
+ if (inputRef.current) {
+ inputRef.current.value = ''
+ }
+ }, [value, setPendingCopilotMessage])
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ handleSubmit()
+ }
+ },
+ [handleSubmit]
+ )
+
+ const handleChange = useCallback((e: React.ChangeEvent) => {
+ setValue(e.target.value)
+ }, [])
+
+ if (isPanelOpen) return null
+
+ return (
+
+
+
+
+
+
+
+
+ Ask the copilot
+
+
+
+
+
+
+
+ )
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts
index 7c32706966d..9cc21cde6e3 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts
@@ -1,6 +1,7 @@
export { BlockMenu } from './block-menu'
export { CanvasMenu } from './canvas-menu'
export { CommandList } from './command-list/command-list'
+export { CopilotInput } from './copilot-input/copilot-input'
export { Cursors } from './cursors/cursors'
export { DiffControls } from './diff-controls/diff-controls'
export { ErrorBoundary } from './error/index'
@@ -9,6 +10,8 @@ export { Panel } from './panel/panel'
export { SubflowNodeComponent } from './subflows/subflow-node'
export { Terminal } from './terminal/terminal'
export { WandPromptBar } from './wand-prompt-bar/wand-prompt-bar'
+export { WorkflowActions } from './workflow-actions'
export { WorkflowBlock } from './workflow-block/workflow-block'
export { WorkflowControls } from './workflow-controls'
export { WorkflowEdge } from './workflow-edge/workflow-edge'
+export { WorkflowToolbar } from './workflow-toolbar'
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
index 82c09c5817c..825a1aa12ad 100644
--- 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
@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
-import { FileText, MoreVertical, Pencil, RotateCcw, SendToBack } from 'lucide-react'
+import { MoreVertical, NotepadText, Pencil, RotateCcw, SendToBack } from 'lucide-react'
import {
Button,
Popover,
@@ -304,7 +304,7 @@ export function Versions({
)}
onClick={() => handleOpenDescriptionModal(v.version)}
>
-
+
@@ -329,10 +329,7 @@ export function Versions({
Rename
- handleOpenDescriptionModal(v.version)}>
-
- {v.description ? 'Edit description' : 'Add description'}
-
+
{!v.isActive && (
handlePromote(v.version)}>
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 4c5ecbbc570..316209f018b 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
@@ -2,47 +2,28 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
-import { History, Plus, Square } from 'lucide-react'
-import { useParams, useRouter } from 'next/navigation'
+import { Bot, History, Pencil, Plus, TerminalSquare, Wrench } from 'lucide-react'
+import { useParams } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow'
import {
- BubbleChatClose,
- BubbleChatPreview,
Button,
- Copy,
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
- Layout,
- Modal,
- ModalBody,
- ModalContent,
- ModalFooter,
- ModalHeader,
- MoreHorizontal,
- Play,
Popover,
PopoverContent,
PopoverItem,
PopoverScrollArea,
PopoverSection,
PopoverTrigger,
+ Tooltip,
Trash,
} from '@/components/emcn'
-import { Lock, Unlock, Upload } from '@/components/emcn/icons'
-import { VariableIcon } from '@/components/icons'
import { useSession } from '@/lib/auth/auth-client'
-import { generateWorkflowJson } from '@/lib/workflows/operations/import-export'
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components'
import { getWorkflowCopilotUseChatOptions, useChat } from '@/app/workspace/[workspaceId]/home/hooks'
import type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
-import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import {
- Deploy,
Editor,
Toolbar,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components'
@@ -50,30 +31,20 @@ import {
usePanelResize,
useUsageLimits,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks'
+import { Terminal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal'
import { Variables } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables'
-import { useAutoLayout } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-auto-layout'
-import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
-import { getWorkflowLockToggleIds } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
-import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
-import { useDuplicateWorkflowMutation, useWorkflowMap } from '@/hooks/queries/workflows'
-import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
-import { useChatStore } from '@/stores/chat/store'
-import { useNotificationStore } from '@/stores/notifications/store'
import type { ChatContext, PanelTab } from '@/stores/panel'
import { usePanelStore } from '@/stores/panel'
-import { useVariablesModalStore } from '@/stores/variables/modal'
-import { useVariablesStore } from '@/stores/variables/store'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { captureBaselineSnapshot } from '@/stores/workflow-diff/utils'
-import { getWorkflowWithValues } from '@/stores/workflows'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
-import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('Panel')
+
/**
* Panel component with resizable width and tab navigation that persists across page refreshes.
*
@@ -97,19 +68,31 @@ interface PanelProps {
}
export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: PanelProps = {}) {
- const router = useRouter()
const params = useParams()
const workspaceId = propWorkspaceId ?? (params.workspaceId as string)
const panelRef = useRef(null)
- const fileInputRef = useRef(null)
- const { activeTab, setActiveTab, panelWidth, _hasHydrated, setHasHydrated } = usePanelStore(
+ const {
+ activeTab,
+ setActiveTab,
+ panelWidth,
+ isPanelOpen,
+ setIsPanelOpen,
+ _hasHydrated,
+ setHasHydrated,
+ pendingCopilotMessage,
+ setPendingCopilotMessage,
+ } = usePanelStore(
useShallow((state) => ({
activeTab: state.activeTab,
setActiveTab: state.setActiveTab,
panelWidth: state.panelWidth,
+ isPanelOpen: state.isPanelOpen,
+ setIsPanelOpen: state.setIsPanelOpen,
_hasHydrated: state._hasHydrated,
setHasHydrated: state.setHasHydrated,
+ pendingCopilotMessage: state.pendingCopilotMessage,
+ setPendingCopilotMessage: state.setPendingCopilotMessage,
}))
)
const toolbarRef = useRef<{
@@ -117,19 +100,8 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
} | null>(null)
const { data: session } = useSession()
- // State
- const [isMenuOpen, setIsMenuOpen] = useState(false)
- const [isAutoLayouting, setIsAutoLayouting] = useState(false)
- const [isExporting, setIsExporting] = useState(false)
- const [isDuplicating, setIsDuplicating] = useState(false)
- const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
-
// Hooks
- const userPermissions = useUserPermissionsContext()
const { config: permissionConfig } = usePermissionConfig()
- const { isImporting, handleFileChange } = useImportWorkflow({ workspaceId })
- const duplicateWorkflowMutation = useDuplicateWorkflowMutation()
- const { data: workflows = {} } = useWorkflowMap(workspaceId)
const { activeWorkflowId, hydration } = useWorkflowRegistry(
useShallow((state) => ({
activeWorkflowId: state.activeWorkflowId,
@@ -137,31 +109,8 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
}))
)
const isRegistryLoading = hydration.phase === 'idle' || hydration.phase === 'state-loading'
- const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null)
-
- // Check for locked blocks (disables auto-layout)
- const hasLockedBlocks = useWorkflowStore((state) =>
- Object.values(state.blocks).some((block) => block.locked)
- )
-
- const allBlocksLocked = useWorkflowStore((state) => {
- const blockList = Object.values(state.blocks)
- return blockList.length > 0 && blockList.every((block) => block.locked)
- })
-
- const hasBlocks = useWorkflowStore((state) => Object.keys(state.blocks).length > 0)
-
- const { collaborativeBatchToggleLocked } = useCollaborativeWorkflow()
const { navigateToSettings } = useSettingsNavigation()
- // Delete workflow hook
- const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({
- workspaceId,
- workflowIds: activeWorkflowId || '',
- isActive: true,
- onSuccess: () => setIsDeleteModalOpen(false),
- })
-
// Usage limits hook
const { usageExceeded } = useUsageLimits({
context: 'user',
@@ -174,23 +123,14 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
// Panel resize hook
const { handleMouseDown } = usePanelResize()
- /**
- * Opens subscription settings modal
- */
const openSubscriptionSettings = () => {
navigateToSettings({ section: 'subscription' })
}
- /**
- * Cancels the currently executing workflow
- */
const cancelWorkflow = useCallback(async () => {
await handleCancelExecution()
}, [handleCancelExecution])
- /**
- * Runs the workflow with usage limit check
- */
const runWorkflow = useCallback(async () => {
if (usageExceeded) {
openSubscriptionSettings()
@@ -200,22 +140,6 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
}, [usageExceeded, handleRunWorkflow])
// Chat state
- const { isChatOpen, setIsChatOpen } = useChatStore(
- useShallow((state) => ({
- isChatOpen: state.isChatOpen,
- setIsChatOpen: state.setIsChatOpen,
- }))
- )
- const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesModalStore(
- useShallow((state) => ({
- isOpen: state.isOpen,
- setIsOpen: state.setIsOpen,
- }))
- )
-
- const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null
- const { isSnapshotView } = useCurrentWorkflow()
-
const [copilotChatId, setCopilotChatId] = useState(undefined)
const [copilotChatTitle, setCopilotChatTitle] = useState(null)
const [copilotChatList, setCopilotChatList] = useState<
@@ -403,6 +327,14 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
[copilotSendMessage]
)
+ useEffect(() => {
+ if (pendingCopilotMessage && isPanelOpen && activeTab === 'copilot') {
+ const message = pendingCopilotMessage
+ setPendingCopilotMessage(null)
+ handleCopilotSubmit(message)
+ }
+ }, [pendingCopilotMessage, isPanelOpen, activeTab, setPendingCopilotMessage, handleCopilotSubmit])
+
/**
* Mark hydration as complete on mount
* This allows React to take over visibility control from CSS
@@ -423,153 +355,27 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
}, [setActiveTab, copilotSendMessage])
/**
- * Handles tab click events
- */
- const handleTabClick = (tab: PanelTab) => {
- setActiveTab(tab)
- }
-
- /**
- * Downloads a file with the given content
- */
- const downloadFile = useCallback((content: string, filename: string, mimeType: string) => {
- try {
- const blob = new Blob([content], { type: mimeType })
- const url = URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- a.download = filename
- document.body.appendChild(a)
- a.click()
- document.body.removeChild(a)
- URL.revokeObjectURL(url)
- } catch (error) {
- logger.error('Failed to download file:', error)
- }
- }, [])
-
- /**
- * Handles auto-layout of workflow blocks
- */
- const handleAutoLayout = useCallback(async () => {
- if (isExecuting || !userPermissions.canEdit || isAutoLayouting) {
- return
- }
-
- setIsAutoLayouting(true)
- try {
- const result = await autoLayoutWithFitView()
- if (!result.success && result.error) {
- useNotificationStore.getState().addNotification({
- level: 'info',
- message: result.error,
- workflowId: activeWorkflowId || undefined,
- })
- }
- } finally {
- setIsAutoLayouting(false)
- }
- }, [
- isExecuting,
- userPermissions.canEdit,
- isAutoLayouting,
- autoLayoutWithFitView,
- activeWorkflowId,
- ])
-
- /**
- * Handles exporting workflow as JSON
+ * Context-aware tab switching:
+ * When a workflow run finishes, auto-switch to the Logs tab so the user
+ * can immediately see results — unless they're actively editing a block.
*/
- const handleExportJson = useCallback(async () => {
- if (!currentWorkflow || !activeWorkflowId) {
- logger.warn('No active workflow to export')
- return
- }
-
- setIsExporting(true)
- try {
- const workflow = getWorkflowWithValues(activeWorkflowId, workspaceId)
-
- if (!workflow || !workflow.state) {
- throw new Error('No workflow state found')
+ const wasExecutingRef = useRef(false)
+ useEffect(() => {
+ if (wasExecutingRef.current && !isExecuting) {
+ if (activeTab !== 'editor' && activeTab !== 'copilot') {
+ setActiveTab('logs')
+ if (!isPanelOpen) setIsPanelOpen(true)
}
-
- const workflowVariables = useVariablesStore
- .getState()
- .getVariablesByWorkflowId(activeWorkflowId)
-
- const jsonContent = generateWorkflowJson(workflow.state, {
- workflowId: activeWorkflowId,
- name: currentWorkflow.name,
- description: currentWorkflow.description,
- variables: workflowVariables.map((v) => ({
- id: v.id,
- name: v.name,
- type: v.type,
- value: v.value,
- })),
- })
-
- const filename = `${currentWorkflow.name.replace(/[^a-z0-9]/gi, '-')}.json`
- downloadFile(jsonContent, filename, 'application/json')
- logger.info('Workflow exported as JSON')
- } catch (error) {
- logger.error('Failed to export workflow as JSON:', error)
- } finally {
- setIsExporting(false)
- setIsMenuOpen(false)
- }
- }, [currentWorkflow, activeWorkflowId, downloadFile])
-
- /**
- * Handles duplicating the current workflow
- */
- const handleDuplicateWorkflow = useCallback(async () => {
- if (!activeWorkflowId || !userPermissions.canEdit || isDuplicating) {
- return
}
+ wasExecutingRef.current = isExecuting
+ }, [isExecuting, activeTab, isPanelOpen, setActiveTab, setIsPanelOpen])
- const sourceWorkflow = workflows[activeWorkflowId]
- if (!sourceWorkflow) return
-
- setIsDuplicating(true)
- try {
- const result = await duplicateWorkflowMutation.mutateAsync({
- workspaceId,
- sourceId: activeWorkflowId,
- name: `${sourceWorkflow.name} (Copy)`,
- description: sourceWorkflow.description,
- color: sourceWorkflow.color ?? '',
- folderId: sourceWorkflow.folderId,
- })
- if (result?.id) {
- router.push(`/workspace/${workspaceId}/w/${result.id}`)
- }
- } catch (error) {
- logger.error('Error duplicating workflow:', error)
- } finally {
- setIsDuplicating(false)
- setIsMenuOpen(false)
+ const handleTabClick = (tab: PanelTab) => {
+ if (!isPanelOpen) {
+ setIsPanelOpen(true)
}
- }, [activeWorkflowId, userPermissions.canEdit, isDuplicating, workflows, router, workspaceId])
-
- /**
- * Toggles the locked state of all blocks in the workflow
- */
- const handleToggleWorkflowLock = useCallback(() => {
- const blocks = useWorkflowStore.getState().blocks
- const allLocked = Object.values(blocks).every((b) => b.locked)
- const ids = getWorkflowLockToggleIds(blocks, !allLocked)
- if (ids.length > 0) collaborativeBatchToggleLocked(ids)
- setIsMenuOpen(false)
- }, [collaborativeBatchToggleLocked])
-
- // Compute run button state
- const canRun = userPermissions.canRead // Running only requires read permissions
- const isLoadingPermissions = userPermissions.isLoading
- const hasValidationErrors = false // TODO: Add validation logic if needed
- const isWorkflowBlocked = isExecuting || hasValidationErrors
- const isButtonDisabled = !isExecuting && (isWorkflowBlocked || (!canRun && !isLoadingPermissions))
+ setActiveTab(tab)
+ }
/**
* Register global keyboard shortcuts using the central commands registry.
@@ -595,6 +401,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
{
id: 'focus-toolbar-search',
handler: () => {
+ if (!isPanelOpen) setIsPanelOpen(true)
setActiveTab('toolbar')
toolbarRef.current?.focusSearch()
},
@@ -605,274 +412,352 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
])
)
- return (
- <>
-
-
- {/* Header */}
-
- {/* More and Chat */}
-
-
-
-
-
-
-
-
-
-
- Auto layout
-
- setVariablesOpen(!isVariablesOpen)}>
-
- Variables
-
- {userPermissions.canAdmin && !isSnapshotView && (
-
- {allBlocksLocked ? : }
- {allBlocksLocked ? 'Unlock workflow' : 'Lock workflow'}
-
- )}
-
-
- Export workflow
-
-
-
- Duplicate workflow
-
- {
- setIsDeleteModalOpen(true)
- }}
- disabled={!userPermissions.canEdit || Object.keys(workflows).length <= 1}
- >
-
- Delete workflow
-
-
-
+ if (!isPanelOpen) {
+ return (
+ <>
+
+ {/* Expand handle on the left border */}
+
+
+ setIsPanelOpen(true)}
+ aria-label='Expand panel'
+ >
+
+
+
+
+
+ Expand panel
+
+
+
+
setIsChatOpen(!isChatOpen)}
+ className='h-[28px] w-[28px] rounded-md p-0'
+ variant='ghost'
+ onClick={() => {
+ setIsPanelOpen(true)
+ setActiveTab('editor')
+ }}
>
- {isChatOpen ? : }
+
-
-
- {/* Deploy and Run */}
-
-
+
+
Editor
+
+
+
runWorkflow()}
- disabled={!isExecuting && isButtonDisabled}
+ className='h-[28px] w-[28px] rounded-md p-0'
+ variant='ghost'
+ onClick={() => {
+ setIsPanelOpen(true)
+ setActiveTab('toolbar')
+ }}
>
- {isExecuting ? (
-
- ) : (
-
- )}
- {isExecuting ? 'Stop' : 'Run'}
+
-
-
-
- {/* Tabs */}
-
-
- {!permissionConfig.hideCopilot && (
+
+ Blocks
+
+
+
+ {
+ setIsPanelOpen(true)
+ setActiveTab('logs')
+ }}
+ >
+
+
+
+ Logs
+
+ {!permissionConfig.hideCopilot && (
+
+
handleTabClick('copilot')}
- data-tab-button='copilot'
- data-tour='tab-copilot'
+ className='h-[28px] w-[28px] rounded-md p-0'
+ variant='ghost'
+ onClick={() => {
+ setIsPanelOpen(true)
+ setActiveTab('copilot')
+ }}
>
- Copilot
+
- )}
- handleTabClick('toolbar')}
- data-tab-button='toolbar'
- data-tour='tab-toolbar'
+
+ Copilot
+
+ )}
+
+
+ >
+ )
+ }
+
+ return (
+ <>
+ {/* Wrapper for collapse handle — matches panel position but no overflow clip */}
+
+
+
+ setIsPanelOpen(false)}
+ aria-label='Collapse panel'
+ >
+
- Toolbar
-
+
+
+
+
+ Collapse panel
+
+
+
+
+ {/* Header: tabs — ordered by workflow priority */}
+
+
+ handleTabClick('editor')}
+ data-tab-button='editor'
+ data-tour='tab-editor'
+ >
+ Editor
+
+ handleTabClick('toolbar')}
+ data-tab-button='toolbar'
+ data-tour='tab-toolbar'
+ >
+ Blocks
+
+ handleTabClick('logs')}
+ data-tab-button='logs'
+ >
+ Logs
+
+ {!permissionConfig.hideCopilot && (
handleTabClick('editor')}
- data-tab-button='editor'
- data-tour='tab-editor'
+ variant={_hasHydrated && activeTab === 'copilot' ? 'active' : 'ghost'}
+ onClick={() => handleTabClick('copilot')}
+ data-tab-button='copilot'
+ data-tour='tab-copilot'
>
- Editor
+ Copilot
-
-
-
- {/* Tab Content - Keep all tabs mounted but hidden to preserve state */}
-
- {!permissionConfig.hideCopilot && (
-
- {/* Copilot Header */}
-
-
- {copilotChatTitle || 'New Chat'}
-
-
-
-
-
-
{
- setIsCopilotHistoryOpen(open)
- if (open) loadCopilotChats()
- }}
- >
-
-
-
-
-
-
- {copilotChatList.length === 0 ? (
-
- No chats yet
-
- ) : (
-
- Recent
-
- {copilotChatList.map((chat) => (
-
-
handleCopilotSelectChat(chat)}
- >
-
- {
- e.stopPropagation()
- handleCopilotDeleteChat(chat.id)
- }}
- aria-label='Delete chat'
- >
-
-
-
- }
- />
-
-
- ))}
-
-
- )}
-
-
-
-
-
-
-
)}
+
+
+
+ {!permissionConfig.hideCopilot && (
-
-
-
-
+ {/* Copilot Header */}
+
+
+ {copilotChatTitle || 'New Chat'}
+
+
+
+
+
+
{
+ setIsCopilotHistoryOpen(open)
+ if (open) loadCopilotChats()
+ }}
+ >
+
+
+
+
+
+
+ {copilotChatList.length === 0 ? (
+
+ No chats yet
+
+ ) : (
+
+ Recent
+
+ {copilotChatList.map((chat) => (
+
+
handleCopilotSelectChat(chat)}
+ >
+
+ {
+ e.stopPropagation()
+ handleCopilotDeleteChat(chat.id)
+ }}
+ aria-label='Delete chat'
+ >
+
+
+
+ }
+ />
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
+ {/* Proactive suggestions when copilot is empty */}
+ {copilotMessages.length === 0 && !copilotIsSending && (
+
+
Try asking the copilot:
+
+ {[
+ 'Run a test on this workflow',
+ 'What does this workflow do?',
+ 'Add error handling to this flow',
+ 'Help me debug the last failed run',
+ ].map((suggestion) => (
+ handleCopilotSubmit(suggestion)}
+ >
+ {suggestion}
+
+ ))}
+
+
+ )}
+
+
+ )}
+
+
+
+
+
+
+
+
-
- {/* Resize Handle */}
-
- {/* Delete Confirmation Modal */}
-
-
- Delete Workflow
-
-
- Are you sure you want to delete{' '}
-
- {currentWorkflow?.name ?? 'this workflow'}
-
- ?{' '}
-
- All associated blocks, executions, and configuration will be removed.
- {' '}
-
- You can restore it from Recently Deleted in Settings.
-
-
-
-
- setIsDeleteModalOpen(false)}
- disabled={isDeleting}
- >
- Cancel
-
-
- {isDeleting ? 'Deleting...' : 'Delete'}
-
-
-
-
-
- {/* Floating Variables Modal */}
>
)
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 9272103bab7..a8b3b795eb8 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
@@ -61,7 +61,6 @@ import { sendMothershipMessage } from '@/stores/notifications/utils'
import type { ConsoleEntry } from '@/stores/terminal'
import {
safeConsoleStringify,
- useConsoleEntry,
useTerminalConsoleStore,
useTerminalStore,
useWorkflowConsoleEntries,
@@ -132,25 +131,36 @@ const BlockRow = memo(function BlockRow({
{entry.blockName}
-
+ {entry.startedAt && (
+
+ {new Date(entry.startedAt).toLocaleTimeString([], {
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ })}
+
)}
- >
-
-
+
+
+
+
)
})
@@ -553,15 +563,15 @@ function TerminalLogListRow({
if (row.rowType === 'separator') {
return (
-
-
+
)
}
return (
-
-
+
+
(null)
const prevWorkflowEntriesLengthRef = useRef(0)
const hasInitializedEntriesRef = useRef(false)
@@ -690,8 +705,10 @@ export const Terminal = memo(function Terminal() {
const clearWorkflowConsole = useTerminalConsoleStore((state) => state.clearWorkflowConsole)
const exportConsoleCSV = useTerminalConsoleStore((state) => state.exportConsoleCSV)
- const [selectedEntryId, setSelectedEntryId] = useState(null)
- const selectedEntry = useConsoleEntry(selectedEntryId)
+ const [selectedEntry, setSelectedEntry] = useState(null)
+ const selectedEntryId = selectedEntry?.id ?? null
+ const [panelOutputHeight, setPanelOutputHeight] = useState(0)
+ const splitContainerRef = useRef(null)
const [expandedNodes, setExpandedNodes] = useState>(() => new Set())
const [isToggling, setIsToggling] = useState(false)
const [showCopySuccess, setShowCopySuccess] = useState(false)
@@ -904,13 +921,19 @@ export const Terminal = memo(function Terminal() {
const handleSelectEntry = useCallback(
(entry: ConsoleEntry) => {
focusTerminal()
- setSelectedEntryId((prev) => {
- // Disable auto-select on any manual selection/deselection
- setAutoSelectEnabled(false)
- return prev === entry.id ? null : entry.id
- })
+ setAutoSelectEnabled(false)
+ if (isPanelMode) {
+ setSelectedEntry(entry)
+ if (panelOutputHeight === 0) {
+ const container = splitContainerRef.current
+ const h = container ? Math.round(container.clientHeight * 0.4) : 200
+ setPanelOutputHeight(h)
+ }
+ } else {
+ setSelectedEntry((prev) => (prev?.id === entry.id ? null : entry))
+ }
},
- [focusTerminal]
+ [focusTerminal, isPanelMode, panelOutputHeight]
)
/**
@@ -963,7 +986,7 @@ export const Terminal = memo(function Terminal() {
const clearCurrentWorkflowConsole = useCallback(() => {
if (activeWorkflowId) {
clearWorkflowConsole(activeWorkflowId)
- setSelectedEntryId(null)
+ setSelectedEntry(null)
setExpandedNodes(new Set())
}
}, [activeWorkflowId, clearWorkflowConsole])
@@ -1094,7 +1117,7 @@ export const Terminal = memo(function Terminal() {
useEffect(() => {
if (executionGroups.length === 0 || navigableEntries.length === 0) {
setAutoSelectEnabled(true)
- setSelectedEntryId(null)
+ setSelectedEntry(null)
return
}
@@ -1114,7 +1137,7 @@ export const Terminal = memo(function Terminal() {
if (!lastNavEntry) return
if (selectedEntryId === lastNavEntry.entry.id) return
- setSelectedEntryId(lastNavEntry.entry.id)
+ setSelectedEntry(lastNavEntry.entry)
focusTerminal()
if (lastNavEntry.parentNodeIds.length > 0) {
@@ -1128,6 +1151,23 @@ export const Terminal = memo(function Terminal() {
}
}, [executionGroups, navigableEntries, autoSelectEnabled, selectedEntryId, focusTerminal])
+ useEffect(() => {
+ if (!selectedEntry) return
+ const updatedEntry = filteredEntries.find((e) => e.id === selectedEntry.id)
+ if (updatedEntry && updatedEntry !== selectedEntry) {
+ const hasChanged =
+ updatedEntry.output !== selectedEntry.output ||
+ updatedEntry.isRunning !== selectedEntry.isRunning ||
+ updatedEntry.isCanceled !== selectedEntry.isCanceled ||
+ updatedEntry.durationMs !== selectedEntry.durationMs ||
+ updatedEntry.error !== selectedEntry.error ||
+ updatedEntry.success !== selectedEntry.success
+ if (hasChanged) {
+ setSelectedEntry(updatedEntry)
+ }
+ }
+ }, [filteredEntries, selectedEntry])
+
/**
* Clear filters when there are no logs
*/
@@ -1143,7 +1183,7 @@ export const Terminal = memo(function Terminal() {
const navigateToEntry = useCallback(
(navEntry: NavigableBlockEntry) => {
setAutoSelectEnabled(false)
- setSelectedEntryId(navEntry.entry.id)
+ setSelectedEntry(navEntry.entry)
// Auto-expand parent nodes (subflows, iterations)
if (navEntry.parentNodeIds.length > 0) {
@@ -1183,7 +1223,7 @@ export const Terminal = memo(function Terminal() {
if (e.key === 'Escape') {
if (currentEntry) {
e.preventDefault()
- setSelectedEntryId(null)
+ setSelectedEntry(null)
setAutoSelectEnabled(true)
}
return
@@ -1250,20 +1290,26 @@ export const Terminal = memo(function Terminal() {
const handleResize = () => {
if (!selectedEntry) return
- const sidebarWidth = Number.parseInt(
- getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
- )
- const panelWidth = Number.parseInt(
- getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
- )
+ let terminalWidth: number
+
+ if (isPanelMode && terminalRef.current) {
+ terminalWidth = terminalRef.current.clientWidth
+ } else {
+ const sidebarWidth = Number.parseInt(
+ getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
+ )
+ const panelWidth = Number.parseInt(
+ getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
+ )
+ terminalWidth = window.innerWidth - sidebarWidth - panelWidth
+ }
- const terminalWidth = window.innerWidth - sidebarWidth - panelWidth
const maxWidth = terminalWidth - TERMINAL_CONFIG.BLOCK_COLUMN_WIDTH_PX
// Close output panel if there's not enough space for minimum width
if (maxWidth < MIN_OUTPUT_PANEL_WIDTH_PX) {
setAutoSelectEnabled(false)
- setSelectedEntryId(null)
+ setSelectedEntry(null)
return
}
@@ -1296,8 +1342,11 @@ export const Terminal = memo(function Terminal() {
- {/* Resize Handle */}
-
+ {/* Resize Handle - only in standalone mode */}
+ {!isPanelMode && (
+
+ )}
-
- {/* Left Section - Logs */}
+
+ {/* Top/Left Section - Logs */}
0
+ ? { flex: '1 1 auto', minHeight: 60 }
+ : undefined
+ : selectedEntry
+ ? { width: `calc(100% - ${outputPanelWidth}px)` }
+ : undefined
+ }
>
+ {/* Panel mode: compact filter bar */}
+ {isPanelMode && !selectedEntry && allWorkflowEntries.length > 0 && (
+
+
clearFilters()}
+ size='sm'
+ >
+ All
+
+
toggleStatus('error')}
+ size='sm'
+ >
+ Errors
+
+
toggleStatus('info')}
+ size='sm'
+ >
+ Success
+
+
+
{
+ e.stopPropagation()
+ toggleSort()
+ }}
+ className='!p-1 -m-1'
+ aria-label='Sort by timestamp'
+ >
+ {sortConfig.direction === 'desc' ? (
+
+ ) : (
+
+ )}
+
+ {filteredEntries.length > 0 && (
+
+
+
+ )}
+
+ )}
+
{/* Header */}
{/* Left side - Logs label */}
- Logs
+ {!isPanelMode && Logs }
{/* Right side - Icons and options */}
{!selectedEntry && (
@@ -1471,13 +1596,15 @@ export const Terminal = memo(function Terminal() {
- {
- e.stopPropagation()
- handleHeaderClick()
- }}
- />
+ {!isPanelMode && (
+ {
+ e.stopPropagation()
+ handleHeaderClick()
+ }}
+ />
+ )}
)}
@@ -1500,8 +1627,94 @@ export const Terminal = memo(function Terminal() {
- {/* Right Section - Block Output (Overlay) */}
- {selectedEntry && (
+ {/* Bottom output panel — panel mode: collapsible, resizable */}
+ {isPanelMode && (
+ 0 ? `${panelOutputHeight}px` : '0px' }}
+ >
+ {/* Drag handle */}
+
{
+ e.preventDefault()
+ const startY = e.clientY
+ const startH = panelOutputHeight
+ const container = splitContainerRef.current
+ const maxH = container ? container.clientHeight - 60 : 400
+ const onMove = (ev: MouseEvent) => {
+ const delta = startY - ev.clientY
+ setPanelOutputHeight(Math.min(maxH, Math.max(0, startH + delta)))
+ }
+ const onUp = () => {
+ document.removeEventListener('mousemove', onMove)
+ document.removeEventListener('mouseup', onUp)
+ }
+ document.addEventListener('mousemove', onMove)
+ document.addEventListener('mouseup', onUp)
+ }}
+ role='separator'
+ aria-orientation='horizontal'
+ aria-label='Resize output panel'
+ >
+
+
+
+ {/* Output content */}
+ {selectedEntry ? (
+
+ {/* Output/Input tab header */}
+
+
setShowInput(false)}
+ >
+ Output
+
+ {hasInputData && (
+
setShowInput(true)}
+ >
+ Input
+
+ )}
+
+
+ {selectedEntry.blockName}
+
+
+ {/* Scrollable content */}
+
+
+ {outputData
+ ? typeof outputData === 'string'
+ ? outputData
+ : JSON.stringify(outputData, null, 2)
+ : 'No data'}
+
+
+
+ ) : (
+
+
+ Select a log entry to view output
+
+
+ )}
+
+ )}
+
+ {/* Right Section - Block Output (standalone mode only) */}
+ {!isPanelMode && selectedEntry && (
({ activeWorkflowId: state.activeWorkflowId }))
+ )
+ const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null)
+ const duplicateWorkflowMutation = useDuplicateWorkflowMutation()
+ const { collaborativeBatchToggleLocked } = useCollaborativeWorkflow()
+ const { isExecuting } = useWorkflowExecution()
+ const { isSnapshotView } = useCurrentWorkflow()
+ const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null
+
+ const hasLockedBlocks = useWorkflowStore((state) =>
+ Object.values(state.blocks).some((block) => block.locked)
+ )
+ const allBlocksLocked = useWorkflowStore((state) => {
+ const blockList = Object.values(state.blocks)
+ return blockList.length > 0 && blockList.every((block) => block.locked)
+ })
+ const hasBlocks = useWorkflowStore((state) => Object.keys(state.blocks).length > 0)
+
+ const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({
+ workspaceId,
+ workflowIds: activeWorkflowId || '',
+ isActive: true,
+ onSuccess: () => setIsDeleteModalOpen(false),
+ })
+
+ const handleAutoLayout = useCallback(async () => {
+ if (isExecuting || !userPermissions.canEdit || isAutoLayouting) return
+ setIsAutoLayouting(true)
+ try {
+ const result = await autoLayoutWithFitView()
+ if (!result.success && result.error) {
+ useNotificationStore.getState().addNotification({
+ level: 'info',
+ message: result.error,
+ workflowId: activeWorkflowId || undefined,
+ })
+ }
+ } finally {
+ setIsAutoLayouting(false)
+ }
+ }, [
+ isExecuting,
+ userPermissions.canEdit,
+ isAutoLayouting,
+ autoLayoutWithFitView,
+ activeWorkflowId,
+ ])
+
+ const downloadFile = useCallback((content: string, filename: string, mimeType: string) => {
+ try {
+ const blob = new Blob([content], { type: mimeType })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = filename
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ URL.revokeObjectURL(url)
+ } catch (error) {
+ logger.error('Failed to download file:', error)
+ }
+ }, [])
+
+ const handleExportJson = useCallback(async () => {
+ if (!currentWorkflow || !activeWorkflowId) return
+ setIsExporting(true)
+ try {
+ const workflow = getWorkflowWithValues(activeWorkflowId, workspaceId)
+ if (!workflow || !workflow.state) throw new Error('No workflow state found')
+ const workflowVariables = useVariablesStore
+ .getState()
+ .getVariablesByWorkflowId(activeWorkflowId)
+ const jsonContent = generateWorkflowJson(workflow.state, {
+ workflowId: activeWorkflowId,
+ name: currentWorkflow.name,
+ description: currentWorkflow.description,
+ variables: workflowVariables.map((v) => ({
+ id: v.id,
+ name: v.name,
+ type: v.type,
+ value: v.value,
+ })),
+ })
+ const filename = `${currentWorkflow.name.replace(/[^a-z0-9]/gi, '-')}.json`
+ downloadFile(jsonContent, filename, 'application/json')
+ } catch (error) {
+ logger.error('Failed to export workflow as JSON:', error)
+ } finally {
+ setIsExporting(false)
+ setIsMoreOpen(false)
+ }
+ }, [currentWorkflow, activeWorkflowId, downloadFile, workspaceId])
+
+ const handleDuplicateWorkflow = useCallback(async () => {
+ if (!activeWorkflowId || !userPermissions.canEdit || isDuplicating) return
+ const sourceWorkflow = workflows[activeWorkflowId]
+ if (!sourceWorkflow) return
+ setIsDuplicating(true)
+ try {
+ const result = await duplicateWorkflowMutation.mutateAsync({
+ workspaceId,
+ sourceId: activeWorkflowId,
+ name: `${sourceWorkflow.name} (Copy)`,
+ description: sourceWorkflow.description,
+ color: sourceWorkflow.color ?? '',
+ folderId: sourceWorkflow.folderId,
+ })
+ if (result?.id) router.push(`/workspace/${workspaceId}/w/${result.id}`)
+ } catch (error) {
+ logger.error('Error duplicating workflow:', error)
+ } finally {
+ setIsDuplicating(false)
+ setIsMoreOpen(false)
+ }
+ }, [
+ activeWorkflowId,
+ userPermissions.canEdit,
+ isDuplicating,
+ workflows,
+ router,
+ workspaceId,
+ duplicateWorkflowMutation,
+ ])
+
+ const handleToggleWorkflowLock = useCallback(() => {
+ const blocks = useWorkflowStore.getState().blocks
+ const allLocked = Object.values(blocks).every((b) => b.locked)
+ const ids = getWorkflowLockToggleIds(blocks, !allLocked)
+ if (ids.length > 0) collaborativeBatchToggleLocked(ids)
+ setIsMoreOpen(false)
+ }, [collaborativeBatchToggleLocked])
+
+ return (
+ <>
+
+ {/* Auto layout */}
+
+
+
+
+
+
+ Auto layout
+
+
+ {/* History */}
+
+
+
+
+ {/* More actions */}
+
+
+
+
+
+
+
+
+
+ {!isMoreOpen && More actions }
+
+
+ {userPermissions.canAdmin && !isSnapshotView && (
+
+ {allBlocksLocked ? : }
+ {allBlocksLocked ? 'Unlock workflow' : 'Lock workflow'}
+
+ )}
+
+
+ Export workflow
+
+
+
+ Duplicate workflow
+
+ setIsDeleteModalOpen(true)}
+ disabled={!userPermissions.canEdit || Object.keys(workflows).length <= 1}
+ >
+
+ Delete workflow
+
+
+
+
+
+ {/* Delete Confirmation Modal */}
+
+
+ Delete Workflow
+
+
+ Are you sure you want to delete{' '}
+
+ {currentWorkflow?.name ?? 'this workflow'}
+
+ ?{' '}
+
+ All associated blocks, executions, and configuration will be removed.
+ {' '}
+
+ You can restore it from Recently Deleted in Settings.
+
+
+
+
+ setIsDeleteModalOpen(false)}
+ disabled={isDeleting}
+ >
+ Cancel
+
+
+ {isDeleting ? 'Deleting...' : 'Delete'}
+
+
+
+
+ >
+ )
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx
index 29873d45ab7..c96313f2528 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx
@@ -3,7 +3,7 @@
import { memo, useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Scan } from 'lucide-react'
-import { useReactFlow } from 'reactflow'
+import { useReactFlow, useViewport } from 'reactflow'
import { useShallow } from 'zustand/react/shallow'
import {
Button,
@@ -18,6 +18,8 @@ import {
Redo,
Tooltip,
Undo,
+ ZoomIn,
+ ZoomOut,
} from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
@@ -36,6 +38,7 @@ const logger = createLogger('WorkflowControls')
*/
export const WorkflowControls = memo(function WorkflowControls() {
const reactFlowInstance = useReactFlow()
+ const { zoom } = useViewport()
const { fitViewToBounds } = useCanvasViewport(reactFlowInstance)
const { mode, setMode } = useCanvasModeStore(
useShallow((s) => ({ mode: s.mode, setMode: s.setMode }))
@@ -52,10 +55,20 @@ export const WorkflowControls = memo(function WorkflowControls() {
const canUndo = stack.undo.length > 0
const canRedo = stack.redo.length > 0
+ const zoomPercentage = Math.round(zoom * 100)
+
const handleFitToView = useCallback(() => {
fitViewToBounds({ padding: 0.1, duration: 300 })
}, [fitViewToBounds])
+ const handleZoomIn = useCallback(() => {
+ reactFlowInstance.zoomIn({ duration: 200 })
+ }, [reactFlowInstance])
+
+ const handleZoomOut = useCallback(() => {
+ reactFlowInstance.zoomOut({ duration: 200 })
+ }, [reactFlowInstance])
+
useRegisterGlobalCommands([
createCommand({
id: 'fit-to-view',
@@ -121,7 +134,13 @@ export const WorkflowControls = memo(function WorkflowControls() {
{mode === 'hand' ? 'Mover' : 'Pointer'}
-
+
{
setMode('hand')
@@ -193,6 +212,39 @@ export const WorkflowControls = memo(function WorkflowControls() {
Fit to View
+
+
+
+ {/* Zoom controls */}
+
+
+
+
+
+
+ Zoom Out
+
+
+
+ {zoomPercentage}%
+
+
+
+
+
+
+
+
+ Zoom In
+
state.activeWorkflowId)
+ const { isExecuting, isSourceActive } = useExecutionStore(
+ useShallow((state) => {
+ const wf = activeWorkflowId ? state.workflowExecutions.get(activeWorkflowId) : undefined
+ return {
+ isExecuting: wf?.isExecuting ?? false,
+ isSourceActive: wf?.activeBlockIds.has(source) ?? false,
+ }
+ })
+ )
+
const dataSourceHandle = (data as { sourceHandle?: string } | undefined)?.sourceHandle
const isErrorEdge = (sourceHandle ?? dataSourceHandle) === 'error'
const previewExecutionStatus = (
@@ -84,6 +96,9 @@ const WorkflowEdgeComponent = ({
targetHandle,
])
+ const showFlowAnimation =
+ !edgeDiffStatus && (edgeRunStatus === 'success' || (isExecuting && isSourceActive))
+
const edgeStyle = useMemo(() => {
let color = 'var(--workflow-edge)'
let opacity = 1
@@ -126,6 +141,20 @@ const WorkflowEdgeComponent = ({
<>
+ {/* Animated flow overlay — subtle dashes that march along the edge path */}
+ {showFlowAnimation && (
+
+ )}
+
{isSelected && (
void
+}
+
+export const WorkflowHistory = memo(function WorkflowHistory({
+ open,
+ onOpenChange,
+}: WorkflowHistoryProps) {
+ const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
+
+ const snapshots = useWorkflowHistoryStore((state) =>
+ activeWorkflowId ? state.getSnapshots(activeWorkflowId) : []
+ )
+ const restoreSnapshot = useWorkflowHistoryStore((state) => state.restoreSnapshot)
+ const clearHistory = useWorkflowHistoryStore((state) => state.clearHistory)
+
+ const { data: versionsData, isLoading: versionsLoading } = useDeploymentVersions(
+ activeWorkflowId,
+ { enabled: open }
+ )
+ const versions = versionsData?.versions ?? []
+
+ const activateVersionMutation = useActivateDeploymentVersion()
+ const revertMutation = useRevertToVersion()
+ const renameMutation = useUpdateDeploymentVersion()
+
+ const [openDropdown, setOpenDropdown] = useState
(null)
+
+ const [editingVersion, setEditingVersion] = useState(null)
+ const [editValue, setEditValue] = useState('')
+ const inputRef = useRef(null)
+
+ const [descriptionModalVersion, setDescriptionModalVersion] = useState(null)
+
+ const [showLoadDialog, setShowLoadDialog] = useState(false)
+ const [showPromoteDialog, setShowPromoteDialog] = useState(false)
+ const [versionToLoad, setVersionToLoad] = useState(null)
+ const [versionToPromote, setVersionToPromote] = useState(null)
+
+ const versionToLoadInfo = versions.find((v) => v.version === versionToLoad)
+ const versionToPromoteInfo = versions.find((v) => v.version === versionToPromote)
+ const descriptionModalVersionData =
+ descriptionModalVersion !== null
+ ? versions.find((v) => v.version === descriptionModalVersion)
+ : null
+
+ useEffect(() => {
+ if (editingVersion !== null && inputRef.current) {
+ inputRef.current.focus()
+ inputRef.current.select()
+ }
+ }, [editingVersion])
+
+ useEffect(() => {
+ if (!open) {
+ setOpenDropdown(null)
+ setEditingVersion(null)
+ setEditValue('')
+ }
+ }, [open])
+
+ const handleRestore = useCallback(
+ (snapshot: WorkflowSnapshot) => {
+ if (!activeWorkflowId) return
+ restoreSnapshot(activeWorkflowId, snapshot.id)
+ onOpenChange(false)
+ },
+ [activeWorkflowId, restoreSnapshot, onOpenChange]
+ )
+
+ const handleClear = useCallback(() => {
+ if (!activeWorkflowId) return
+ clearHistory(activeWorkflowId)
+ }, [activeWorkflowId, clearHistory])
+
+ const handleStartRename = useCallback(
+ (version: number, currentName: string | null | undefined) => {
+ setOpenDropdown(null)
+ setEditingVersion(version)
+ setEditValue(currentName || `v${version}`)
+ },
+ []
+ )
+
+ const handleSaveRename = useCallback(
+ (version: number) => {
+ if (renameMutation.isPending) return
+ if (!activeWorkflowId || !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
+ }
+
+ renameMutation.mutate(
+ {
+ workflowId: activeWorkflowId,
+ version,
+ name: editValue.trim(),
+ },
+ {
+ onSuccess: () => setEditingVersion(null),
+ }
+ )
+ },
+ [activeWorkflowId, editValue, versions, renameMutation]
+ )
+
+ const handleCancelRename = useCallback(() => {
+ setEditingVersion(null)
+ setEditValue('')
+ }, [])
+
+ const handleOpenDescriptionModal = useCallback((version: number) => {
+ setOpenDropdown(null)
+ setDescriptionModalVersion(version)
+ }, [])
+
+ const handlePromote = useCallback((version: number) => {
+ setOpenDropdown(null)
+ setVersionToPromote(version)
+ setShowPromoteDialog(true)
+ }, [])
+
+ const handleLoadDeployment = useCallback((version: number) => {
+ setOpenDropdown(null)
+ setVersionToLoad(version)
+ setShowLoadDialog(true)
+ }, [])
+
+ const confirmPromoteToLive = useCallback(async () => {
+ if (!activeWorkflowId || versionToPromote === null) return
+ setShowPromoteDialog(false)
+ const version = versionToPromote
+ setVersionToPromote(null)
+
+ try {
+ await activateVersionMutation.mutateAsync({
+ workflowId: activeWorkflowId,
+ version,
+ })
+ } catch (error) {
+ logger.error('Failed to promote version:', error)
+ }
+ }, [activeWorkflowId, versionToPromote, activateVersionMutation])
+
+ const confirmLoadDeployment = useCallback(async () => {
+ if (!activeWorkflowId || versionToLoad === null) return
+ setShowLoadDialog(false)
+ const version = versionToLoad
+ setVersionToLoad(null)
+ onOpenChange(false)
+
+ try {
+ await revertMutation.mutateAsync({ workflowId: activeWorkflowId, version })
+ } catch (error) {
+ logger.error('Failed to load deployment:', error)
+ }
+ }, [activeWorkflowId, versionToLoad, revertMutation, onOpenChange])
+
+ const hasVersions = versions.length > 0
+ const hasSnapshots = snapshots.length > 0
+ const isEmpty = !hasVersions && !hasSnapshots && !versionsLoading
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ {!open && Change history }
+
+ {
+ e.preventDefault()
+ requestAnimationFrame(() => onOpenChange(false))
+ }}
+ >
+ {/* Header */}
+
+
+ Change History
+
+ onOpenChange(false)}
+ >
+
+
+
+
+ {isEmpty ? (
+
+
+
+ No history yet
+
+
+ Deploy your workflow or make edits to see history
+
+
+ ) : (
+
+ {/* Deployment Versions Section */}
+ {(hasVersions || versionsLoading) && (
+ <>
+ Deployment Versions
+ {versionsLoading && !hasVersions ? (
+
+ Loading...
+
+ ) : (
+ versions.map((v) => (
+
+ ))
+ )}
+ >
+ )}
+
+ {/* Recent Changes Section */}
+ {hasSnapshots && (
+ <>
+ {hasVersions &&
}
+ Recent Changes
+ {snapshots.map((snapshot) => (
+ handleRestore(snapshot)}>
+
+ {snapshot.label}
+
+ {formatTime(snapshot.timestamp)}
+
+
+ ))}
+
+
+
+ Clear local history
+
+
+ >
+ )}
+
+ )}
+
+
+
+ {/* Confirmation: Load Deployment */}
+
+
+ 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
+
+
+
+
+
+ {/* Confirmation: Promote to Live */}
+
+
+ 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
+
+
+
+
+
+ {/* Version Description Modal */}
+ {activeWorkflowId && descriptionModalVersionData && (
+ !openState && setDescriptionModalVersion(null)}
+ workflowId={activeWorkflowId}
+ version={descriptionModalVersionData.version}
+ versionName={
+ descriptionModalVersionData.name || `v${descriptionModalVersionData.version}`
+ }
+ currentDescription={descriptionModalVersionData.description}
+ />
+ )}
+ >
+ )
+})
+
+interface VersionRowProps {
+ version: WorkflowDeploymentVersionResponse
+ workflowId: string | null
+ editingVersion: number | null
+ editValue: string
+ inputRef: React.RefObject
+ openDropdown: number | null
+ renamePending: boolean
+ onEditValueChange: (value: string) => void
+ onSaveRename: (version: number) => void
+ onCancelRename: () => void
+ onStartRename: (version: number, currentName: string | null | undefined) => void
+ onOpenDropdown: (version: number | null) => void
+ onOpenDescription: (version: number) => void
+ onPromote: (version: number) => void
+ onLoadDeployment: (version: number) => void
+}
+
+function VersionRow({
+ version: v,
+ editingVersion,
+ editValue,
+ inputRef,
+ openDropdown,
+ renamePending,
+ onEditValueChange,
+ onSaveRename,
+ onCancelRename,
+ onStartRename,
+ onOpenDropdown,
+ onOpenDescription,
+ onPromote,
+ onLoadDeployment,
+}: VersionRowProps) {
+ const isEditing = editingVersion === v.version
+
+ return (
+
+ {/* Status dot */}
+
+
+ {/* Version name + timestamp */}
+
+ {isEditing ? (
+ onEditValueChange(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ onSaveRename(v.version)
+ } else if (e.key === 'Escape') {
+ e.preventDefault()
+ onCancelRename()
+ }
+ }}
+ onClick={(e) => e.stopPropagation()}
+ onBlur={() => onSaveRename(v.version)}
+ className='w-full border-0 bg-transparent p-0 font-medium text-[12px] text-[var(--text-primary)] leading-4 outline-none focus:outline-none focus:ring-0'
+ maxLength={100}
+ disabled={renamePending}
+ autoComplete='off'
+ autoCorrect='off'
+ autoCapitalize='off'
+ spellCheck='false'
+ />
+ ) : (
+
+ {v.name || `v${v.version}`}
+ {v.isActive && (live) }
+
+ )}
+
+ {formatDateTime(new Date(v.createdAt))}
+ {v.deployedBy ? ` · ${v.deployedBy}` : ''}
+
+
+
+ {/* Actions */}
+
e.stopPropagation()}>
+
+
+ onOpenDescription(v.version)}
+ >
+
+
+
+
+ {v.description ? (
+ {v.description}
+ ) : (
+ Add description
+ )}
+
+
+
+
onOpenDropdown(isOpen ? v.version : null)}
+ >
+
+
+
+
+
+
+ onStartRename(v.version, v.name)}>
+
+ Rename
+
+ {!v.isActive && (
+ onPromote(v.version)}>
+
+ Promote to live
+
+ )}
+ onLoadDeployment(v.version)}>
+
+ Load deployment
+
+
+
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-toolbar.tsx
new file mode 100644
index 00000000000..4bef375993c
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-toolbar.tsx
@@ -0,0 +1,109 @@
+'use client'
+
+import { memo, useCallback } from 'react'
+import { Square } from 'lucide-react'
+import { useParams } from 'next/navigation'
+import { useShallow } from 'zustand/react/shallow'
+import { Button, Play, Tooltip } from '@/components/emcn'
+import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
+import { Deploy } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components'
+import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks'
+import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
+import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
+import { useVariablesStore } from '@/stores/variables/store'
+import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
+
+interface WorkflowToolbarProps {
+ workspaceId?: string
+}
+
+export const WorkflowToolbar = memo(function WorkflowToolbar({
+ workspaceId: propWorkspaceId,
+}: WorkflowToolbarProps) {
+ const params = useParams()
+ const workspaceId = propWorkspaceId ?? (params.workspaceId as string)
+
+ const userPermissions = useUserPermissionsContext()
+ const { activeWorkflowId, hydration } = useWorkflowRegistry(
+ useShallow((state) => ({
+ activeWorkflowId: state.activeWorkflowId,
+ hydration: state.hydration,
+ }))
+ )
+ const isRegistryLoading = hydration.phase === 'idle' || hydration.phase === 'state-loading'
+ const { navigateToSettings } = useSettingsNavigation()
+
+ const { usageExceeded } = useUsageLimits({
+ context: 'user',
+ autoRefresh: !isRegistryLoading,
+ })
+
+ const { handleRunWorkflow, handleCancelExecution, isExecuting } = useWorkflowExecution()
+
+ const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesStore(
+ useShallow((state) => ({
+ isOpen: state.isOpen,
+ setIsOpen: state.setIsOpen,
+ }))
+ )
+
+ const cancelWorkflow = useCallback(async () => {
+ await handleCancelExecution()
+ }, [handleCancelExecution])
+
+ const runWorkflow = useCallback(async () => {
+ if (usageExceeded) {
+ navigateToSettings({ section: 'subscription' })
+ return
+ }
+ await handleRunWorkflow()
+ }, [usageExceeded, handleRunWorkflow, navigateToSettings])
+
+ const canRun = userPermissions.canRead
+ const isLoadingPermissions = userPermissions.isLoading
+ const isButtonDisabled = !isExecuting && !canRun && !isLoadingPermissions
+
+ return (
+
+ {/* Variables */}
+
setVariablesOpen(!isVariablesOpen)}
+ >
+ Variables
+
+
+ {/* Run */}
+
+
+ runWorkflow()}
+ disabled={!isExecuting && isButtonDisabled}
+ >
+ {isExecuting ? (
+
+ ) : (
+
+ )}
+ {isExecuting ? 'Stop' : 'Run'}
+
+
+
+ Run workflow
+
+
+
+
+
+ {/* Deploy (primary CTA) */}
+
+
+
+
+ )
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
index 0a8c196c60c..80ac3751132 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
@@ -1,12 +1,15 @@
'use client'
-import React, { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import ReactFlow, {
applyNodeChanges,
+ Background,
+ BackgroundVariant,
ConnectionLineType,
type Edge,
type EdgeTypes,
+ MiniMap,
type Node,
type NodeChange,
type NodeTypes,
@@ -27,11 +30,11 @@ import { OAuthModal } from '@/app/workspace/[workspaceId]/components/oauth-modal
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
CommandList,
+ CopilotInput,
DiffControls,
Notifications,
Panel,
SubflowNodeComponent,
- Terminal,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
import { BlockMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu'
import { CanvasMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu'
@@ -39,9 +42,11 @@ import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
import type { SubflowNodeData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
+import { WorkflowActions } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-actions/workflow-actions'
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { WorkflowControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
+import { WorkflowToolbar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-toolbar/workflow-toolbar'
import {
useAutoLayout,
useCanvasContextMenu,
@@ -83,7 +88,7 @@ import { useChatStore } from '@/stores/chat/store'
import { defaultWorkflowExecutionState, useExecutionStore } from '@/stores/execution'
import { useSearchModalStore } from '@/stores/modals/search/store'
import { useNotificationStore } from '@/stores/notifications'
-import { usePanelEditorStore } from '@/stores/panel'
+import { usePanelEditorStore, usePanelStore } from '@/stores/panel'
import { useUndoRedoStore } from '@/stores/undo-redo'
import { useVariablesModalStore } from '@/stores/variables/modal'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
@@ -92,13 +97,6 @@ import { getUniqueBlockName, prepareBlockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState } from '@/stores/workflows/workflow/types'
-/** Lazy-loaded components for non-critical UI that can load after initial render */
-const LazyChat = lazy(() =>
- import('@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat').then((mod) => ({
- default: mod.Chat,
- }))
-)
-
const logger = createLogger('Workflow')
const DEFAULT_PASTE_OFFSET = { x: 50, y: 50 }
@@ -200,7 +198,7 @@ const reactFlowStyles = [
'[&_.react-flow__edge-labels]:!z-[1001]',
'[&_.react-flow__pane]:select-none',
'[&_.react-flow__selectionpane]:select-none',
- '[&_.react-flow__background]:hidden',
+
'[&_.react-flow__node-subflowNode.selected]:!shadow-none',
].join(' ')
const reactFlowFitViewOptions = { padding: 0.6, maxZoom: 1.0 } as const
@@ -3676,6 +3674,7 @@ const WorkflowContent = React.memo(
const onPaneClick = useCallback(() => {
setSelectedEdges(new Map())
usePanelEditorStore.getState().clearCurrentBlock()
+ usePanelStore.getState().setIsPanelOpen(false)
}, [])
/**
@@ -4042,17 +4041,33 @@ const WorkflowContent = React.memo(
elevateNodesOnSelect={false}
autoPanOnConnect={effectivePermissions.canEdit}
autoPanOnNodeDrag={effectivePermissions.canEdit}
- />
+ >
+
+
{!embedded && (
<>
+
+
-
-
-
-
+
}
-
-
-
+ {!embedded &&
}
- {(!embedded || sandbox) &&
}
+ {(!embedded || sandbox) &&
}
+
+
{!embedded && !sandbox && oauthModal && (
-
)
}
diff --git a/apps/sim/components/emcn/components/button/button.tsx b/apps/sim/components/emcn/components/button/button.tsx
index d04dfcd47a4..e6af70c5c11 100644
--- a/apps/sim/components/emcn/components/button/button.tsx
+++ b/apps/sim/components/emcn/components/button/button.tsx
@@ -25,6 +25,8 @@ const buttonVariants = cva(
subtle:
'text-[var(--text-body)] hover-hover:text-[var(--text-body)] hover-hover:bg-[var(--surface-4)]',
'ghost-secondary': 'text-[var(--text-muted)] hover-hover:text-[var(--text-primary)]',
+ brand:
+ 'bg-[var(--brand)] text-white hover-hover:text-white hover-hover:bg-[var(--brand-hover)] font-semibold',
},
size: {
sm: 'px-1.5 py-1 text-[length:11px]',
diff --git a/apps/sim/stores/panel/editor/store.ts b/apps/sim/stores/panel/editor/store.ts
index f78c35b73f3..7516f83e8e6 100644
--- a/apps/sim/stores/panel/editor/store.ts
+++ b/apps/sim/stores/panel/editor/store.ts
@@ -48,7 +48,9 @@ export const usePanelEditorStore = create
()(
setCurrentBlockId: (blockId) => {
set({ currentBlockId: blockId })
if (blockId !== null) {
- usePanelStore.getState().setActiveTab('editor')
+ const panelStore = usePanelStore.getState()
+ panelStore.setActiveTab('editor')
+ panelStore.setIsPanelOpen(true)
}
},
clearCurrentBlock: () => {
diff --git a/apps/sim/stores/panel/store.ts b/apps/sim/stores/panel/store.ts
index 5e7d0c74015..21f98567740 100644
--- a/apps/sim/stores/panel/store.ts
+++ b/apps/sim/stores/panel/store.ts
@@ -29,10 +29,21 @@ export const usePanelStore = create()(
document.documentElement.removeAttribute('data-panel-active-tab')
}
},
+ isPanelOpen: true,
+ setIsPanelOpen: (isOpen) => {
+ set({ isPanelOpen: isOpen })
+ },
isResizing: false,
setIsResizing: (isResizing) => {
set({ isResizing })
},
+ pendingCopilotMessage: null,
+ setPendingCopilotMessage: (message) => {
+ set({ pendingCopilotMessage: message })
+ if (message !== null) {
+ set({ isPanelOpen: true, activeTab: 'copilot' })
+ }
+ },
_hasHydrated: false,
setHasHydrated: (hasHydrated) => {
set({ _hasHydrated: hasHydrated })
@@ -40,6 +51,11 @@ export const usePanelStore = create()(
}),
{
name: 'panel-state',
+ partialize: (state) => ({
+ panelWidth: state.panelWidth,
+ activeTab: state.activeTab,
+ isPanelOpen: state.isPanelOpen,
+ }),
onRehydrateStorage: () => (state) => {
// Sync CSS variables with stored state after rehydration
if (state && typeof window !== 'undefined') {
diff --git a/apps/sim/stores/panel/types.ts b/apps/sim/stores/panel/types.ts
index 5d0bf4eca21..88ecc824889 100644
--- a/apps/sim/stores/panel/types.ts
+++ b/apps/sim/stores/panel/types.ts
@@ -1,7 +1,7 @@
/**
* Available panel tabs
*/
-export type PanelTab = 'copilot' | 'editor' | 'toolbar'
+export type PanelTab = 'copilot' | 'editor' | 'toolbar' | 'logs'
/**
* Panel state interface
@@ -11,10 +11,14 @@ export interface PanelState {
setPanelWidth: (width: number) => void
activeTab: PanelTab
setActiveTab: (tab: PanelTab) => void
+ isPanelOpen: boolean
+ setIsPanelOpen: (isOpen: boolean) => void
/** Whether the panel is currently being resized */
isResizing: boolean
/** Updates the panel resize state */
setIsResizing: (isResizing: boolean) => void
+ pendingCopilotMessage: string | null
+ setPendingCopilotMessage: (message: string | null) => void
_hasHydrated: boolean
setHasHydrated: (hasHydrated: boolean) => void
}
diff --git a/apps/sim/stores/variables/store.ts b/apps/sim/stores/variables/store.ts
index 0980f2ae6b3..14f6a8ab3ef 100644
--- a/apps/sim/stores/variables/store.ts
+++ b/apps/sim/stores/variables/store.ts
@@ -64,6 +64,9 @@ export const useVariablesStore = create()(
isLoading: false,
error: null,
isEditing: null,
+ isOpen: false,
+
+ setIsOpen: (open) => set({ isOpen: open }),
addVariable: (variable, providedId?: string) => {
const id = providedId || crypto.randomUUID()
@@ -138,14 +141,21 @@ export const useVariablesStore = create()(
if (targetWorkflowId) {
const workflowValues = subBlockStore.workflowValues[targetWorkflowId] || {}
const updatedWorkflowValues = { ...workflowValues }
- const changedSubBlocks: Array<{ blockId: string; subBlockId: string; value: any }> =
- []
+ const changedSubBlocks: Array<{
+ blockId: string
+ subBlockId: string
+ value: unknown
+ }> = []
const oldVarName = normalizeName(oldVariableName)
const newVarName = normalizeName(newName)
const regex = new RegExp(``, 'gi')
- const updateReferences = (value: any, pattern: RegExp, replacement: string): any => {
+ const updateReferences = (
+ value: unknown,
+ pattern: RegExp,
+ replacement: string
+ ): unknown => {
if (typeof value === 'string') {
return pattern.test(value) ? value.replace(pattern, replacement) : value
}
@@ -155,7 +165,7 @@ export const useVariablesStore = create()(
}
if (value !== null && typeof value === 'object') {
- const result = { ...value }
+ const result = { ...(value as Record) }
for (const key in result) {
result[key] = updateReferences(result[key], pattern, replacement)
}
@@ -166,7 +176,7 @@ export const useVariablesStore = create()(
}
Object.entries(workflowValues).forEach(([blockId, blockValues]) => {
- Object.entries(blockValues as Record).forEach(
+ Object.entries(blockValues as Record).forEach(
([subBlockId, value]) => {
const updatedValue = updateReferences(value, regex, ``)
@@ -174,14 +184,14 @@ export const useVariablesStore = create()(
if (!updatedWorkflowValues[blockId]) {
updatedWorkflowValues[blockId] = { ...workflowValues[blockId] }
}
- updatedWorkflowValues[blockId][subBlockId] = updatedValue
+ ;(updatedWorkflowValues[blockId] as Record)[subBlockId] =
+ updatedValue
changedSubBlocks.push({ blockId, subBlockId, value: updatedValue })
}
}
)
})
- // Update local state
useSubBlockStore.setState({
workflowValues: {
...subBlockStore.workflowValues,
@@ -189,7 +199,6 @@ export const useVariablesStore = create()(
},
})
- // Queue operations for persistence via socket
const operationQueue = useOperationQueueStore.getState()
for (const { blockId, subBlockId, value } of changedSubBlocks) {
diff --git a/apps/sim/stores/variables/types.ts b/apps/sim/stores/variables/types.ts
index a3b43e5be8b..358340707d5 100644
--- a/apps/sim/stores/variables/types.ts
+++ b/apps/sim/stores/variables/types.ts
@@ -22,6 +22,8 @@ export interface VariablesStore {
isLoading: boolean
error: string | null
isEditing: string | null
+ isOpen: boolean
+ setIsOpen: (open: boolean) => void
/**
* Adds a new variable with automatic name uniqueness validation
diff --git a/apps/sim/stores/workflow-history/index.ts b/apps/sim/stores/workflow-history/index.ts
new file mode 100644
index 00000000000..ce43fb3c0a1
--- /dev/null
+++ b/apps/sim/stores/workflow-history/index.ts
@@ -0,0 +1,2 @@
+export { useWorkflowHistoryStore } from './store'
+export type { WorkflowHistoryState, WorkflowSnapshot } from './types'
diff --git a/apps/sim/stores/workflow-history/store.ts b/apps/sim/stores/workflow-history/store.ts
new file mode 100644
index 00000000000..08c1cc0bacb
--- /dev/null
+++ b/apps/sim/stores/workflow-history/store.ts
@@ -0,0 +1,198 @@
+import { createLogger } from '@sim/logger'
+import { create } from 'zustand'
+import { devtools, persist } from 'zustand/middleware'
+import { useUndoRedoStore } from '@/stores/undo-redo'
+import type { UndoRedoState } from '@/stores/undo-redo/types'
+import type { WorkflowHistoryState, WorkflowSnapshot } from '@/stores/workflow-history/types'
+import { MAX_SNAPSHOTS_PER_WORKFLOW, MAX_TRACKED_WORKFLOWS } from '@/stores/workflow-history/types'
+import { useSubBlockStore } from '@/stores/workflows/subblock/store'
+import { useWorkflowStore } from '@/stores/workflows/workflow/store'
+
+const logger = createLogger('WorkflowHistoryStore')
+
+const OPERATION_LABELS: Record = {
+ 'batch-add-blocks': 'Added blocks',
+ 'batch-remove-blocks': 'Removed blocks',
+ 'batch-add-edges': 'Added connections',
+ 'batch-remove-edges': 'Removed connections',
+ 'batch-move-blocks': 'Moved blocks',
+ 'update-parent': 'Moved to subflow',
+ 'batch-update-parent': 'Moved to subflow',
+ 'batch-toggle-enabled': 'Toggled enabled',
+ 'batch-toggle-handles': 'Toggled handles',
+ 'batch-toggle-locked': 'Toggled locked',
+ 'apply-diff': 'Applied changes',
+ 'accept-diff': 'Accepted changes',
+ 'reject-diff': 'Rejected changes',
+}
+
+function generateSnapshotId(): string {
+ return `snap_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`
+}
+
+function captureSubBlockValues(workflowId: string): Record> {
+ const workflowValues = useSubBlockStore.getState().workflowValues
+ const values = workflowValues[workflowId]
+ if (!values) return {}
+ return JSON.parse(JSON.stringify(values))
+}
+
+function enforceLRU(
+ snapshots: Record
+): Record {
+ const workflowIds = Object.keys(snapshots)
+ if (workflowIds.length <= MAX_TRACKED_WORKFLOWS) return snapshots
+
+ const sorted = workflowIds
+ .map((id) => ({
+ id,
+ latest: snapshots[id]?.[0]?.timestamp ?? '',
+ }))
+ .sort((a, b) => b.latest.localeCompare(a.latest))
+
+ const keepers = new Set(sorted.slice(0, MAX_TRACKED_WORKFLOWS).map((s) => s.id))
+ const pruned: Record = {}
+ for (const id of keepers) {
+ pruned[id] = snapshots[id]
+ }
+ return pruned
+}
+
+export const useWorkflowHistoryStore = create()(
+ devtools(
+ persist(
+ (set, get) => ({
+ snapshots: {},
+
+ captureSnapshot: (workflowId: string, label: string) => {
+ try {
+ const workflowState = useWorkflowStore.getState().getWorkflowState()
+ const blocks = workflowState.blocks
+ const edges = workflowState.edges
+
+ if (Object.keys(blocks).length === 0) return
+
+ const subBlockValues = captureSubBlockValues(workflowId)
+
+ const snapshot: WorkflowSnapshot = {
+ id: generateSnapshotId(),
+ timestamp: new Date().toISOString(),
+ label,
+ blocks: JSON.parse(JSON.stringify(blocks)),
+ edges: JSON.parse(JSON.stringify(edges)),
+ subBlockValues,
+ }
+
+ set((state) => {
+ const existing = state.snapshots[workflowId] ?? []
+
+ if (existing.length > 0) {
+ const latest = existing[0]
+ const timeDiff = Date.now() - new Date(latest.timestamp).getTime()
+ if (
+ timeDiff < 2000 &&
+ latest.label === label &&
+ Object.keys(latest.blocks).length === Object.keys(blocks).length &&
+ latest.edges.length === edges.length
+ ) {
+ return state
+ }
+ }
+
+ const updated = [snapshot, ...existing].slice(0, MAX_SNAPSHOTS_PER_WORKFLOW)
+
+ return {
+ snapshots: enforceLRU({
+ ...state.snapshots,
+ [workflowId]: updated,
+ }),
+ }
+ })
+ } catch (error) {
+ logger.error('Failed to capture workflow snapshot', { workflowId, error })
+ }
+ },
+
+ restoreSnapshot: (workflowId: string, snapshotId: string): boolean => {
+ try {
+ const snapshots = get().snapshots[workflowId] ?? []
+ const snapshot = snapshots.find((s) => s.id === snapshotId)
+ if (!snapshot) {
+ logger.warn('Snapshot not found', { workflowId, snapshotId })
+ return false
+ }
+
+ get().captureSnapshot(workflowId, 'Before restore')
+
+ const workflowStore = useWorkflowStore.getState()
+ workflowStore.replaceWorkflowState({
+ blocks: JSON.parse(JSON.stringify(snapshot.blocks)),
+ edges: JSON.parse(JSON.stringify(snapshot.edges)),
+ loops: {},
+ parallels: {},
+ })
+
+ const subBlockStore = useSubBlockStore.getState()
+ for (const [blockId, values] of Object.entries(snapshot.subBlockValues)) {
+ for (const [subBlockId, value] of Object.entries(values as Record)) {
+ subBlockStore.setValue(blockId, subBlockId, value)
+ }
+ }
+
+ logger.info('Restored workflow to snapshot', {
+ workflowId,
+ snapshotId,
+ label: snapshot.label,
+ })
+ return true
+ } catch (error) {
+ logger.error('Failed to restore workflow snapshot', { workflowId, snapshotId, error })
+ return false
+ }
+ },
+
+ getSnapshots: (workflowId: string): WorkflowSnapshot[] => {
+ return get().snapshots[workflowId] ?? []
+ },
+
+ clearHistory: (workflowId: string) => {
+ set((state) => {
+ const { [workflowId]: _, ...rest } = state.snapshots
+ return { snapshots: rest }
+ })
+ },
+
+ clearAllHistory: () => {
+ set({ snapshots: {} })
+ },
+ }),
+ {
+ name: 'workflow-history',
+ partialize: (state) => ({ snapshots: state.snapshots }),
+ }
+ ),
+ { name: 'workflow-history-store' }
+ )
+)
+
+if (typeof window !== 'undefined') {
+ const prevStackSizes: Record = {}
+
+ useUndoRedoStore.subscribe((state: UndoRedoState) => {
+ for (const [key, stack] of Object.entries(state.stacks)) {
+ const prevSize = prevStackSizes[key] ?? 0
+ const currentSize = stack.undo.length
+
+ if (currentSize > prevSize && currentSize > 0) {
+ const latestEntry = stack.undo[stack.undo.length - 1]
+ if (latestEntry) {
+ const workflowId = key.split(':')[0]
+ const label = OPERATION_LABELS[latestEntry.operation.type] ?? 'Changed workflow'
+ useWorkflowHistoryStore.getState().captureSnapshot(workflowId, label)
+ }
+ }
+
+ prevStackSizes[key] = currentSize
+ }
+ })
+}
diff --git a/apps/sim/stores/workflow-history/types.ts b/apps/sim/stores/workflow-history/types.ts
new file mode 100644
index 00000000000..3f42a3387de
--- /dev/null
+++ b/apps/sim/stores/workflow-history/types.ts
@@ -0,0 +1,29 @@
+import type { Edge } from 'reactflow'
+import type { BlockState } from '@/stores/workflows/workflow/types'
+
+export interface WorkflowSnapshot {
+ id: string
+ timestamp: string
+ label: string
+ blocks: Record
+ edges: Edge[]
+ subBlockValues: Record>
+}
+
+export const MAX_SNAPSHOTS_PER_WORKFLOW = 50
+
+export const MAX_TRACKED_WORKFLOWS = 5
+
+export interface WorkflowHistoryState {
+ snapshots: Record
+
+ captureSnapshot: (workflowId: string, label: string) => void
+
+ restoreSnapshot: (workflowId: string, snapshotId: string) => boolean
+
+ getSnapshots: (workflowId: string) => WorkflowSnapshot[]
+
+ clearHistory: (workflowId: string) => void
+
+ clearAllHistory: () => void
+}