-
- 0} />
-
-
-
@@ -1525,18 +1506,14 @@ const WorkflowContent = React.memo(() => {
return (
-
- 0} />
-
-
-
+
+ {/* Floating Control Bar */}
+
0} />
+
{
autoPanOnConnect={userPermissions.canEdit}
autoPanOnNodeDrag={userPermissions.canEdit}
>
-
+
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/create-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/create-menu.tsx
index 07891f70f..7e887cf5a 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/create-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/create-menu.tsx
@@ -1,22 +1,21 @@
'use client'
-import { useRef, useState } from 'react'
+import { useCallback, useEffect, useRef, useState } from 'react'
import { logger } from '@sentry/nextjs'
-import { ChevronRight, File, Folder, Plus, Upload } from 'lucide-react'
-import { useParams } from 'next/navigation'
+import { File, Folder, Plus, Upload } from 'lucide-react'
+import { useParams, useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
-import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
import { useFolderStore } from '@/stores/folders/store'
import { ImportControls, type ImportControlsRef } from './import-controls'
interface CreateMenuProps {
- onCreateWorkflow: (folderId?: string) => void
+ onCreateWorkflow: (folderId?: string) => Promise
isCollapsed?: boolean
isCreatingWorkflow?: boolean
}
@@ -29,10 +28,11 @@ export function CreateMenu({
const [showFolderDialog, setShowFolderDialog] = useState(false)
const [folderName, setFolderName] = useState('')
const [isCreating, setIsCreating] = useState(false)
- const [isHoverOpen, setIsHoverOpen] = useState(false)
- const [isImportSubmenuOpen, setIsImportSubmenuOpen] = useState(false)
+ const [isOpen, setIsOpen] = useState(false)
+ const [pressTimer, setPressTimer] = useState(null)
const params = useParams()
+ const router = useRouter()
const workspaceId = params.workspaceId as string
const { createFolder } = useFolderStore()
const userPermissions = useUserPermissionsContext()
@@ -40,22 +40,111 @@ export function CreateMenu({
// Ref for the file input that will be used by ImportControls
const importControlsRef = useRef(null)
- const handleCreateWorkflow = () => {
- setIsHoverOpen(false)
- onCreateWorkflow()
- }
+ const handleCreateWorkflow = useCallback(async () => {
+ if (isCreatingWorkflow) {
+ logger.info('Workflow creation already in progress, ignoring request')
+ return
+ }
- const handleCreateFolder = () => {
- setIsHoverOpen(false)
+ setIsOpen(false)
+
+ try {
+ // Call the parent's workflow creation function and wait for the ID
+ const workflowId = await onCreateWorkflow()
+
+ // Navigate to the new workflow
+ if (workflowId) {
+ router.push(`/workspace/${workspaceId}/w/${workflowId}`)
+ }
+ } catch (error) {
+ logger.error('Error creating workflow:', { error })
+ }
+ }, [onCreateWorkflow, isCreatingWorkflow, router, workspaceId])
+
+ const handleCreateFolder = useCallback(() => {
+ setIsOpen(false)
setShowFolderDialog(true)
- }
+ }, [])
- const handleUploadYaml = () => {
- setIsHoverOpen(false)
- setIsImportSubmenuOpen(false)
+ const handleImportWorkflow = useCallback(() => {
+ setIsOpen(false)
// Trigger the file upload from ImportControls component
importControlsRef.current?.triggerFileUpload()
- }
+ }, [])
+
+ // Handle direct click for workflow creation
+ const handleButtonClick = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+
+ // Clear any existing press timer
+ if (pressTimer) {
+ window.clearTimeout(pressTimer)
+ setPressTimer(null)
+ }
+
+ // Direct workflow creation on click
+ handleCreateWorkflow()
+ },
+ [handleCreateWorkflow, pressTimer]
+ )
+
+ // Handle hover to show popover
+ const handleMouseEnter = useCallback(() => {
+ setIsOpen(true)
+ }, [])
+
+ const handleMouseLeave = useCallback(() => {
+ if (pressTimer) {
+ window.clearTimeout(pressTimer)
+ setPressTimer(null)
+ }
+ setIsOpen(false)
+ }, [pressTimer])
+
+ // Handle dropdown content hover
+ const handlePopoverMouseEnter = useCallback(() => {
+ setIsOpen(true)
+ }, [])
+
+ const handlePopoverMouseLeave = useCallback(() => {
+ setIsOpen(false)
+ }, [])
+
+ // Handle right-click to show popover
+ const handleContextMenu = useCallback((e: React.MouseEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setIsOpen(true)
+ }, [])
+
+ // Handle long press to show popover
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
+ if (e.button === 0) {
+ // Left mouse button
+ const timer = setTimeout(() => {
+ setIsOpen(true)
+ setPressTimer(null)
+ }, 500) // 500ms for long press
+ setPressTimer(timer)
+ }
+ }, [])
+
+ const handleMouseUp = useCallback(() => {
+ if (pressTimer) {
+ window.clearTimeout(pressTimer)
+ setPressTimer(null)
+ }
+ }, [pressTimer])
+
+ useEffect(() => {
+ return () => {
+ if (pressTimer) {
+ window.clearTimeout(pressTimer)
+ }
+ }
+ }, [pressTimer])
const handleFolderSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -83,25 +172,23 @@ export function CreateMenu({
return (
<>
-
+
setIsHoverOpen(true)}
- onMouseLeave={() => setIsHoverOpen(false)}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
+ onMouseEnter={handlePopoverMouseEnter}
+ onMouseLeave={handlePopoverMouseLeave}
>
{userPermissions.canEdit && (
- <>
-
-
-
-
-
-
- setIsImportSubmenuOpen(true)}
- onMouseLeave={() => setIsImportSubmenuOpen(false)}
- onOpenAutoFocus={(e) => e.preventDefault()}
- onCloseAutoFocus={(e) => e.preventDefault()}
- >
-
-
-
- >
+
)}
@@ -191,10 +244,7 @@ export function CreateMenu({
{
- setIsHoverOpen(false)
- setIsImportSubmenuOpen(false)
- }}
+ onClose={() => setIsOpen(false)}
/>
{/* Folder creation dialog */}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/import-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/import-controls.tsx
index 1d555aed5..6c2fca752 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/import-controls.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/import-controls.tsx
@@ -1,19 +1,7 @@
'use client'
import { forwardRef, useImperativeHandle, useRef, useState } from 'react'
-import { AlertCircle, CheckCircle } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
-import { Alert, AlertDescription } from '@/components/ui/alert'
-import { Button } from '@/components/ui/button'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import { Textarea } from '@/components/ui/textarea'
import { createLogger } from '@/lib/logs/console-logger'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -33,7 +21,6 @@ export interface ImportControlsRef {
export const ImportControls = forwardRef(
({ disabled = false, onClose }, ref) => {
const [isImporting, setIsImporting] = useState(false)
- const [showYamlDialog, setShowYamlDialog] = useState(false)
const [yamlContent, setYamlContent] = useState('')
const [importResult, setImportResult] = useState<{
success: boolean
@@ -66,7 +53,9 @@ export const ImportControls = forwardRef
try {
const content = await file.text()
setYamlContent(content)
- setShowYamlDialog(true)
+
+ // Import directly without showing the modal
+ await handleDirectImport(content)
onClose?.()
} catch (error) {
logger.error('Failed to read file:', error)
@@ -85,8 +74,8 @@ export const ImportControls = forwardRef
}
}
- const handleYamlImport = async () => {
- if (!yamlContent.trim()) {
+ const handleDirectImport = async (content: string) => {
+ if (!content.trim()) {
setImportResult({
success: false,
errors: ['YAML content is required'],
@@ -100,7 +89,7 @@ export const ImportControls = forwardRef
try {
// First validate the YAML without importing
- const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(yamlContent)
+ const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(content)
if (!yamlWorkflow || parseErrors.length > 0) {
setImportResult({
@@ -121,13 +110,12 @@ export const ImportControls = forwardRef
// Import the YAML into the new workflow BEFORE navigation (creates complete state and saves directly to DB)
// This avoids timing issues with workflow reload during navigation
const result = await importWorkflowFromYaml(
- yamlContent,
+ content,
{
addBlock: collaborativeAddBlock,
addEdge: collaborativeAddEdge,
applyAutoLayout: () => {
- // Trigger auto layout
- window.dispatchEvent(new CustomEvent('trigger-auto-layout'))
+ // Do nothing - auto layout should not run during import
},
setSubBlockValue: (blockId: string, subBlockId: string, value: unknown) => {
// Use the collaborative function - the same one called when users type into fields
@@ -151,7 +139,6 @@ export const ImportControls = forwardRef
if (result.success) {
setYamlContent('')
- setShowYamlDialog(false)
logger.info('YAML import completed successfully')
}
} catch (error) {
@@ -166,8 +153,6 @@ export const ImportControls = forwardRef
}
}
- const isDisabled = disabled || isImporting
-
return (
<>
{/* Hidden file input */}
@@ -178,103 +163,6 @@ export const ImportControls = forwardRef
onChange={handleFileUpload}
className='hidden'
/>
-
- {/* YAML Import Dialog */}
-
>
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-context-menu/folder-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-context-menu/folder-context-menu.tsx
index 35b85805a..aca7e8fc4 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-context-menu/folder-context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-context-menu/folder-context-menu.tsx
@@ -9,7 +9,6 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
- DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
@@ -27,6 +26,7 @@ interface FolderContextMenuProps {
onCreateWorkflow: (folderId: string) => void
onRename?: (folderId: string, newName: string) => void
onDelete?: (folderId: string) => void
+ level: number
}
export function FolderContextMenu({
@@ -35,6 +35,7 @@ export function FolderContextMenu({
onCreateWorkflow,
onRename,
onDelete,
+ level,
}: FolderContextMenuProps) {
const [showSubfolderDialog, setShowSubfolderDialog] = useState(false)
const [showRenameDialog, setShowRenameDialog] = useState(false)
@@ -131,33 +132,50 @@ export function FolderContextMenu({
- e.stopPropagation()}>
+ e.stopPropagation()}
+ className='min-w-32 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[#202020]'
+ >
{userPermissions.canEdit && (
<>
-
+
New Workflow
-
-
- New Subfolder
-
-
-
+ {level === 0 && (
+
+
+ New Subfolder
+
+ )}
+
Rename
>
)}
{userPermissions.canAdmin ? (
-
+
Delete
@@ -166,7 +184,7 @@ export function FolderContextMenu({
e.preventDefault()}
>
@@ -196,6 +214,7 @@ export function FolderContextMenu({
value={subfolderName}
onChange={(e) => setSubfolderName(e.target.value)}
placeholder='Enter folder name...'
+ maxLength={50}
autoFocus
required
/>
@@ -226,6 +245,7 @@ export function FolderContextMenu({
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
placeholder='Enter folder name...'
+ maxLength={50}
autoFocus
required
/>
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/components/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/components/folder-item.tsx
index 5d0accc0e..c867c1bbc 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/components/folder-item.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/components/folder-item.tsx
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
-import { ChevronDown, ChevronRight, Folder, FolderOpen } from 'lucide-react'
+import { Folder, FolderOpen } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
AlertDialog,
@@ -29,6 +29,8 @@ interface FolderItemProps {
onDragOver?: (e: React.DragEvent) => void
onDragLeave?: (e: React.DragEvent) => void
onDrop?: (e: React.DragEvent) => void
+ isFirstItem?: boolean
+ level: number
}
export function FolderItem({
@@ -39,10 +41,14 @@ export function FolderItem({
onDragOver,
onDragLeave,
onDrop,
+ isFirstItem = false,
+ level,
}: FolderItemProps) {
const { expandedFolders, toggleExpanded, updateFolderAPI, deleteFolder } = useFolderStore()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
+ const [isDragging, setIsDragging] = useState(false)
+ const dragStartedRef = useRef(false)
const params = useParams()
const workspaceId = params.workspaceId as string
const isExpanded = expandedFolders.has(folder.id)
@@ -69,6 +75,39 @@ export function FolderItem({
}, 300)
}, [folder.id, isExpanded, toggleExpanded, updateFolderAPI])
+ const handleDragStart = (e: React.DragEvent) => {
+ dragStartedRef.current = true
+ setIsDragging(true)
+
+ e.dataTransfer.setData('folder-id', folder.id)
+ e.dataTransfer.effectAllowed = 'move'
+
+ // Set global drag state for validation in other components
+ if (typeof window !== 'undefined') {
+ ;(window as any).currentDragFolderId = folder.id
+ }
+ }
+
+ const handleDragEnd = () => {
+ setIsDragging(false)
+ requestAnimationFrame(() => {
+ dragStartedRef.current = false
+ })
+
+ // Clear global drag state
+ if (typeof window !== 'undefined') {
+ ;(window as any).currentDragFolderId = null
+ }
+ }
+
+ const handleClick = (e: React.MouseEvent) => {
+ if (dragStartedRef.current) {
+ e.preventDefault()
+ return
+ }
+ handleToggleExpanded()
+ }
+
useEffect(() => {
return () => {
if (updateTimeoutRef.current) {
@@ -107,11 +146,17 @@ export function FolderItem({
- {folder.name}
+ {folder.name}
@@ -136,7 +181,13 @@ export function FolderItem({
- Are you sure you want to delete "{folder.name}"?
+
+ Are you sure you want to delete "
+
+ {folder.name}
+
+ "?
+
This will permanently delete the folder and all its contents, including subfolders
and workflows. This action cannot be undone.
@@ -160,19 +211,21 @@ export function FolderItem({
return (
<>
-
+
-
- {isExpanded ? (
-
- ) : (
-
- )}
-
-
{isExpanded ? (
@@ -181,9 +234,7 @@ export function FolderItem({
)}
-
- {folder.name}
-
+
{folder.name}
e.stopPropagation()}>
@@ -201,7 +253,13 @@ export function FolderItem({
- Are you sure you want to delete "{folder.name}"?
+
+ Are you sure you want to delete "
+
+ {folder.name}
+
+ "?
+
This will permanently delete the folder and all its contents, including subfolders and
workflows. This action cannot be undone.
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/components/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/components/workflow-item.tsx
index f054a4ca7..33916362e 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/components/workflow-item.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/components/workflow-item.tsx
@@ -8,6 +8,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { createLogger } from '@/lib/logs/console-logger'
import { useFolderStore, useIsWorkflowSelected } from '@/stores/folders/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
+import { WorkflowContextMenu } from '../../workflow-context-menu/workflow-context-menu'
const logger = createLogger('WorkflowItem')
@@ -18,6 +19,7 @@ interface WorkflowItemProps {
isCollapsed?: boolean
level: number
isDragOver?: boolean
+ isFirstItem?: boolean
}
export function WorkflowItem({
@@ -27,6 +29,7 @@ export function WorkflowItem({
isCollapsed,
level,
isDragOver = false,
+ isFirstItem = false,
}: WorkflowItemProps) {
const [isDragging, setIsDragging] = useState(false)
const dragStartedRef = useRef(false)
@@ -81,10 +84,11 @@ export function WorkflowItem({
1 && !active && !isDragOver
? 'bg-accent/70'
@@ -103,7 +107,7 @@ export function WorkflowItem({
-
+
{workflow.name}
{isMarketplace && ' (Preview)'}
@@ -113,31 +117,49 @@ export function WorkflowItem({
}
return (
- 1 && !active && !isDragOver ? 'bg-accent/70' : '',
- isDragging ? 'opacity-50' : '',
- !isMarketplace ? 'cursor-move' : ''
- )}
- style={{ paddingLeft: isCollapsed ? '0px' : `${(level + 1) * 20 + 8}px` }}
- draggable={!isMarketplace}
- onDragStart={handleDragStart}
- onDragEnd={handleDragEnd}
- onClick={handleClick}
- >
+
-
- {workflow.name}
- {isMarketplace && ' (Preview)'}
-
-
+ className={clsx(
+ 'flex h-9 items-center rounded-lg px-2 py-2 font-medium text-sm transition-colors',
+ active && !isDragOver
+ ? 'bg-accent text-foreground'
+ : 'text-muted-foreground hover:bg-accent/50',
+ isSelected && selectedWorkflows.size > 1 && !active && !isDragOver ? 'bg-accent/70' : '',
+ isDragging ? 'opacity-50' : '',
+ 'cursor-pointer',
+ isFirstItem ? 'mr-[44px]' : ''
+ )}
+ style={{
+ maxWidth: isFirstItem
+ ? `${164 - (level >= 0 ? (level + 1) * 20 + 8 : 0) - (level > 0 ? 8 : 0)}px`
+ : `${206 - (level >= 0 ? (level + 1) * 20 + 8 : 0) - (level > 0 ? 8 : 0)}px`,
+ }}
+ draggable={!isMarketplace}
+ onDragStart={handleDragStart}
+ onDragEnd={handleDragEnd}
+ data-workflow-id={workflow.id}
+ >
+
+
+
+ {workflow.name}
+ {isMarketplace && ' (Preview)'}
+
+
+
+ {!isMarketplace && (
+
e.stopPropagation()}>
+
+
+ )}
+
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/folder-tree.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/folder-tree.tsx
index 22710c615..b918319bf 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/folder-tree.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/folder-tree.tsx
@@ -1,8 +1,9 @@
'use client'
-import { useEffect, useState } from 'react'
+import { useCallback, useEffect, useState } from 'react'
import clsx from 'clsx'
import { useParams, usePathname } from 'next/navigation'
+import { Skeleton } from '@/components/ui/skeleton'
import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
@@ -18,12 +19,14 @@ interface FolderSectionProps {
expandedFolders: Set
pathname: string
updateWorkflow: (id: string, updates: Partial) => Promise
+ updateFolder: (id: string, updates: any) => Promise
renderFolderTree: (
nodes: FolderTreeNode[],
level: number,
parentDragOver?: boolean
) => React.ReactNode[]
parentDragOver?: boolean
+ isFirstItem?: boolean
}
function FolderSection({
@@ -35,27 +38,41 @@ function FolderSection({
expandedFolders,
pathname,
updateWorkflow,
+ updateFolder,
renderFolderTree,
parentDragOver = false,
+ isFirstItem = false,
}: FolderSectionProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
- const { isDragOver, handleDragOver, handleDragLeave, handleDrop } = useDragHandlers(
- updateWorkflow,
- folder.id,
- `Moved workflow(s) to folder ${folder.id}`
- )
+ const { isDragOver, isInvalidDrop, handleDragOver, handleDragLeave, handleDrop } =
+ useDragHandlers(
+ updateWorkflow,
+ updateFolder,
+ folder.id,
+ `Moved workflow(s) to folder ${folder.id}`
+ )
const workflowsInFolder = workflowsByFolder[folder.id] || []
const isAnyDragOver = isDragOver || parentDragOver
+ const hasChildren = workflowsInFolder.length > 0 || folder.children.length > 0
+ const isExpanded = expandedFolders.has(folder.id)
return (
- {/* Render workflows in this folder */}
- {expandedFolders.has(folder.id) && workflowsInFolder.length > 0 && (
-
- {workflowsInFolder.map((workflow) => (
-
+ {/* Vertical line from folder icon to children */}
+ {!isCollapsed && (workflowsInFolder.length > 0 || folder.children.length > 0) && (
+
- ))}
-
- )}
+ )}
- {/* Render child folders */}
- {expandedFolders.has(folder.id) && folder.children.length > 0 && (
- {renderFolderTree(folder.children, level + 1, isAnyDragOver)}
+ {/* Render workflows in this folder */}
+ {workflowsInFolder.length > 0 && (
+
+ {workflowsInFolder.map((workflow, index) => (
+
+ {/* Curved corner */}
+ {!isCollapsed && (
+
+ )}
+ {/* Horizontal line to workflow */}
+ {!isCollapsed && (
+
+ )}
+ {/* Workflow container with proper indentation */}
+
+
+
+
+ ))}
+
+ )}
+
+ {/* Render child folders */}
+ {folder.children.length > 0 && (
+
+ {folder.children.map((childFolder, index) => (
+
+ {/* Curved corner */}
+ {!isCollapsed && (
+
+ )}
+ {/* Horizontal line to child folder */}
+ {!isCollapsed && (
+
+ )}
+
+
+
+
+ ))}
+
+ )}
+
)}
)
@@ -100,21 +225,46 @@ function FolderSection({
// Custom hook for drag and drop handling
function useDragHandlers(
updateWorkflow: (id: string, updates: Partial) => Promise,
+ updateFolder: (id: string, updates: any) => Promise,
targetFolderId: string | null, // null for root
logMessage?: string
) {
const [isDragOver, setIsDragOver] = useState(false)
+ const [isInvalidDrop, setIsInvalidDrop] = useState(false)
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragOver(true)
+
+ // Check if this would be an invalid folder drop
+ const draggedFolderId =
+ (typeof window !== 'undefined' && (window as any).currentDragFolderId) || null
+
+ if (draggedFolderId && targetFolderId) {
+ const folderStore = useFolderStore.getState()
+ const targetFolderPath = folderStore.getFolderPath(targetFolderId)
+
+ // Check for circular reference
+ const draggedFolderPath = folderStore.getFolderPath(draggedFolderId)
+ const isCircular =
+ targetFolderId === draggedFolderId ||
+ draggedFolderPath.some((ancestor) => ancestor.id === targetFolderId)
+
+ // Check for deep nesting (target folder already has a parent)
+ const wouldBeDeepNesting = targetFolderPath.length >= 1
+
+ setIsInvalidDrop(isCircular || wouldBeDeepNesting)
+ } else {
+ setIsInvalidDrop(false)
+ }
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragOver(false)
+ setIsInvalidDrop(false)
}
const handleDrop = async (e: React.DragEvent) => {
@@ -122,6 +272,7 @@ function useDragHandlers(
e.stopPropagation()
setIsDragOver(false)
+ // Handle workflow drops
const workflowIdsData = e.dataTransfer.getData('workflow-ids')
if (workflowIdsData) {
const workflowIds = JSON.parse(workflowIdsData) as string[]
@@ -136,10 +287,49 @@ function useDragHandlers(
console.error('Failed to move workflows:', error)
}
}
+
+ // Handle folder drops
+ const folderIdData = e.dataTransfer.getData('folder-id')
+ if (folderIdData) {
+ try {
+ // Check if the target folder would create more than 2 levels of nesting
+ const folderStore = useFolderStore.getState()
+ const targetFolderPath = targetFolderId ? folderStore.getFolderPath(targetFolderId) : []
+
+ // Prevent circular references - don't allow dropping a folder into itself or its descendants
+ if (targetFolderId === folderIdData) {
+ console.log('Cannot move folder into itself')
+ return
+ }
+
+ // Check if target folder is a descendant of the dragged folder
+ const draggedFolderPath = folderStore.getFolderPath(folderIdData)
+ if (
+ targetFolderId &&
+ draggedFolderPath.some((ancestor) => ancestor.id === targetFolderId)
+ ) {
+ console.log('Cannot move folder into its own descendant')
+ return
+ }
+
+ // If target folder is already at level 1 (has 1 parent), we can't nest another folder
+ if (targetFolderPath.length >= 1) {
+ console.log('Cannot nest folder: Maximum 2 levels of nesting allowed. Drop prevented.')
+ return // Prevent the drop entirely
+ }
+
+ // Target folder is at root level, safe to nest
+ await updateFolder(folderIdData, { parentId: targetFolderId })
+ console.log(`Moved folder to ${targetFolderId ? `folder ${targetFolderId}` : 'root'}`)
+ } catch (error) {
+ console.error('Failed to move folder:', error)
+ }
+ }
}
return {
isDragOver,
+ isInvalidDrop,
handleDragOver,
handleDragLeave,
handleDrop,
@@ -170,15 +360,53 @@ export function FolderTree({
fetchFolders,
isLoading: foldersLoading,
clearSelection,
+ updateFolderAPI,
} = useFolderStore()
const { updateWorkflow } = useWorkflowRegistry()
+ // Clean up any existing folders with 3+ levels of nesting
+ const cleanupDeepNesting = useCallback(async () => {
+ const { getFolderTree, updateFolderAPI } = useFolderStore.getState()
+ const folderTree = getFolderTree(workspaceId)
+
+ const findDeepFolders = (nodes: FolderTreeNode[], currentLevel = 0): FolderTreeNode[] => {
+ let deepFolders: FolderTreeNode[] = []
+
+ for (const node of nodes) {
+ if (currentLevel >= 2) {
+ // This folder is at level 2+ (too deep), add it to cleanup list
+ deepFolders.push(node)
+ } else {
+ // Recursively check children
+ deepFolders = deepFolders.concat(findDeepFolders(node.children, currentLevel + 1))
+ }
+ }
+
+ return deepFolders
+ }
+
+ const deepFolders = findDeepFolders(folderTree)
+
+ // Move deeply nested folders to root level
+ for (const folder of deepFolders) {
+ try {
+ await updateFolderAPI(folder.id, { parentId: null })
+ console.log(`Moved deeply nested folder "${folder.name}" to root level`)
+ } catch (error) {
+ console.error(`Failed to move folder "${folder.name}":`, error)
+ }
+ }
+ }, [workspaceId])
+
// Fetch folders when workspace changes
useEffect(() => {
if (workspaceId) {
- fetchFolders(workspaceId)
+ fetchFolders(workspaceId).then(() => {
+ // Clean up any existing deep nesting after folders are loaded
+ cleanupDeepNesting()
+ })
}
- }, [workspaceId, fetchFolders])
+ }, [workspaceId, fetchFolders, cleanupDeepNesting])
useEffect(() => {
clearSelection()
@@ -199,17 +427,18 @@ export function FolderTree({
const {
isDragOver: rootDragOver,
+ isInvalidDrop: rootInvalidDrop,
handleDragOver: handleRootDragOver,
handleDragLeave: handleRootDragLeave,
handleDrop: handleRootDrop,
- } = useDragHandlers(updateWorkflow, null, 'Moved workflow(s) to root')
+ } = useDragHandlers(updateWorkflow, updateFolderAPI, null, 'Moved workflow(s) to root')
const renderFolderTree = (
nodes: FolderTreeNode[],
level = 0,
parentDragOver = false
): React.ReactNode[] => {
- return nodes.map((folder) => (
+ return nodes.map((folder, index) => (
))
}
const showLoading = isLoading || foldersLoading
+ const rootWorkflows = workflowsByFolder.root || []
+
+ // Render skeleton loading state
+ const renderSkeletonLoading = () => {
+ if (isCollapsed) {
+ return (
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+
+
+ ))}
+
+ )
+ }
+
+ return (
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+
+
+
+ ))}
+
+ )
+ }
+
+ if (showLoading) {
+ return renderSkeletonLoading()
+ }
return (
-
+
{/* Folder tree */}
- {renderFolderTree(folderTree)}
+ {renderFolderTree(folderTree, 0, false)}
{/* Root level workflows (no folder) */}
- {(workflowsByFolder.root || []).map((workflow) => (
+ {rootWorkflows.map((workflow, index) => (
))}
- {/* Marketplace workflows */}
- {marketplaceWorkflows.length > 0 && (
-
-
- {isCollapsed ? '' : 'Marketplace'}
-
- {marketplaceWorkflows.map((workflow) => (
-
- ))}
-
- )}
-
{/* Empty state */}
{!showLoading &&
regularWorkflows.length === 0 &&
marketplaceWorkflows.length === 0 &&
folderTree.length === 0 &&
!isCollapsed && (
-
+
No workflows or folders in {workspaceId ? 'this workspace' : 'your account'}. Create one
to get started.
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/nav-section/nav-section.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/nav-section/nav-section.tsx
deleted file mode 100644
index 88b83cde1..000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/nav-section/nav-section.tsx
+++ /dev/null
@@ -1,147 +0,0 @@
-'use client'
-
-import type { ReactNode } from 'react'
-import clsx from 'clsx'
-import Link from 'next/link'
-import { Skeleton } from '@/components/ui/skeleton'
-import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
-
-interface NavSectionProps {
- children: ReactNode
- isLoading?: boolean
- itemCount?: number
- isCollapsed?: boolean
-}
-
-interface NavItemProps {
- icon: ReactNode
- label: string
- href?: string
- active?: boolean
- onClick?: () => void
- isCollapsed?: boolean
- shortcutCommand?: string
- shortcutCommandPosition?: 'inline' | 'below'
-}
-
-export function NavSection({
- children,
- isLoading = false,
- itemCount = 3,
- isCollapsed,
-}: NavSectionProps) {
- if (isLoading) {
- return (
-
- )
- }
-
- return
-}
-
-function NavItem({
- icon,
- label,
- href,
- active,
- onClick,
- isCollapsed,
- shortcutCommand,
- shortcutCommandPosition = 'inline',
-}: NavItemProps) {
- const className = clsx(
- 'flex items-center gap-2 rounded-md px-2 py-[6px] text-sm font-medium',
- active ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-accent/50',
- {
- 'cursor-pointer': onClick,
- 'justify-center': isCollapsed,
- 'w-full': !isCollapsed,
- 'w-8 mx-auto': isCollapsed,
- }
- )
-
- const content = (
- <>
- {isCollapsed ?
{icon}
: icon}
- {!isCollapsed &&
{label}}
- >
- )
-
- if (isCollapsed) {
- if (href) {
- return (
-
-
-
- {content}
-
-
-
- {label}
-
-
- )
- }
-
- return (
-
-
-
-
-
- {label}
-
-
- )
- }
-
- if (href) {
- return (
-
- {content}
-
- )
- }
-
- return (
-
- )
-}
-
-function NavItemSkeleton({ isCollapsed }: { isCollapsed?: boolean }) {
- if (isCollapsed) {
- return (
-
-
-
- )
- }
-
- return (
-
-
-
-
- )
-}
-
-NavSection.Item = NavItem
-NavSection.Skeleton = NavItemSkeleton
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/general/general.tsx
index f0e7b71b7..60167b2af 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/general/general.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/general/general.tsx
@@ -209,7 +209,7 @@ export function General() {
disabled={isLoading}
/>
-
+ {/*
+
*/}
>
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/edit-member-limit-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/edit-member-limit-dialog.tsx
index 527fada10..19470105e 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/edit-member-limit-dialog.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/edit-member-limit-dialog.tsx
@@ -84,7 +84,7 @@ export function EditMemberLimitDialog({
if (newLimit < member.currentUsage) {
setError(
- `The new limit ($${newLimit.toFixed(2)}) cannot be lower than the member's current usage ($${member.currentUsage.toFixed(2)})`
+ `The new limit ($${newLimit.toFixed(2)}) cannot be lower than the member's current usage ($${member.currentUsage?.toFixed(2) || 0})`
)
return
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx
index 7157453e4..4462ea18e 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx
@@ -300,7 +300,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
Current Usage
- ${organizationBillingData.totalCurrentUsage.toFixed(2)}
+ ${organizationBillingData.totalCurrentUsage?.toFixed(2) || 0}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-control/sidebar-control.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-control/sidebar-control.tsx
deleted file mode 100644
index a8c376e86..000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-control/sidebar-control.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { PanelRight } from 'lucide-react'
-import { Button } from '@/components/ui/button'
-import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
-import { cn } from '@/lib/utils'
-import { type SidebarMode, useSidebarStore } from '@/stores/sidebar/store'
-
-// This component ONLY controls sidebar state, not toolbar state
-export function SidebarControl() {
- const { mode, setMode, toggleExpanded, isExpanded } = useSidebarStore()
- const [open, setOpen] = useState(false)
-
- const handleModeChange = (value: SidebarMode) => {
- // When selecting expanded mode, ensure it's expanded
- if (value === 'expanded' && !isExpanded) {
- toggleExpanded()
- }
-
- // Set the new mode
- setMode(value)
- setOpen(false)
- }
-
- return (
-
-
-
-
-
-
-
Sidebar control
-
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/components/toolbar-block/toolbar-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/toolbar/components/toolbar-block/toolbar-block.tsx
similarity index 80%
rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/components/toolbar-block/toolbar-block.tsx
rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/toolbar/components/toolbar-block/toolbar-block.tsx
index f868b84a0..cb45d4af1 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/components/toolbar-block/toolbar-block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/toolbar/components/toolbar-block/toolbar-block.tsx
@@ -40,28 +40,25 @@ export function ToolbarBlock({ config, disabled = false }: ToolbarBlockProps) {
onDragStart={handleDragStart}
onClick={handleClick}
className={cn(
- 'group flex items-center gap-3 rounded-lg border bg-card p-3.5 shadow-sm transition-colors',
+ 'group flex items-center gap-3 rounded-lg p-2 transition-colors',
disabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:bg-accent/50 active:cursor-grabbing'
)}
>
-
-
{config.name}
-
{config.description}
-
+
{config.name}
)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/components/toolbar-loop-block/toolbar-loop-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/toolbar/components/toolbar-loop-block/toolbar-loop-block.tsx
similarity index 79%
rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/components/toolbar-loop-block/toolbar-loop-block.tsx
rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/toolbar/components/toolbar-loop-block/toolbar-loop-block.tsx
index 767de4f40..b65a94b09 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/components/toolbar-loop-block/toolbar-loop-block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/toolbar/components/toolbar-loop-block/toolbar-loop-block.tsx
@@ -2,7 +2,7 @@ import { useCallback } from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
-import { LoopTool } from '../../../loop-node/loop-config'
+import { LoopTool } from '../../../../../../[workflowId]/components/loop-node/loop-config'
type LoopToolbarItemProps = {
disabled?: boolean
@@ -49,27 +49,24 @@ export default function LoopToolbarItem({ disabled = false }: LoopToolbarItemPro
onDragStart={handleDragStart}
onClick={handleClick}
className={cn(
- 'group flex items-center gap-3 rounded-lg border bg-card p-3.5 shadow-sm transition-colors',
+ 'group flex items-center gap-3 rounded-lg p-2 transition-colors',
disabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:bg-accent/50 active:cursor-grabbing'
)}
>
-
-
{LoopTool.name}
-
{LoopTool.description}
-
+ {LoopTool.name}
)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/components/toolbar-parallel-block/toolbar-parallel-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/toolbar/components/toolbar-parallel-block/toolbar-parallel-block.tsx
similarity index 79%
rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/components/toolbar-parallel-block/toolbar-parallel-block.tsx
rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/toolbar/components/toolbar-parallel-block/toolbar-parallel-block.tsx
index 22e1bc397..7c8c6bd89 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/components/toolbar-parallel-block/toolbar-parallel-block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/toolbar/components/toolbar-parallel-block/toolbar-parallel-block.tsx
@@ -2,7 +2,7 @@ import { useCallback } from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
-import { ParallelTool } from '../../../parallel-node/parallel-config'
+import { ParallelTool } from '../../../../../../[workflowId]/components/parallel-node/parallel-config'
type ParallelToolbarItemProps = {
disabled?: boolean
@@ -49,27 +49,24 @@ export default function ParallelToolbarItem({ disabled = false }: ParallelToolba
onDragStart={handleDragStart}
onClick={handleClick}
className={cn(
- 'group flex items-center gap-3 rounded-lg border bg-card p-3.5 shadow-sm transition-colors',
+ 'group flex items-center gap-3 rounded-lg p-2 transition-colors',
disabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:bg-accent/50 active:cursor-grabbing'
)}
>
-
-
{ParallelTool.name}
-
{ParallelTool.description}
-
+ {ParallelTool.name}
)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/toolbar/toolbar.tsx
new file mode 100644
index 000000000..e1b7965fb
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/toolbar/toolbar.tsx
@@ -0,0 +1,138 @@
+'use client'
+
+import { useMemo, useState } from 'react'
+import { Search } from 'lucide-react'
+import { Input } from '@/components/ui/input'
+import { ScrollArea } from '@/components/ui/scroll-area'
+import { getAllBlocks } from '@/blocks'
+import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
+import { ToolbarBlock } from './components/toolbar-block/toolbar-block'
+import LoopToolbarItem from './components/toolbar-loop-block/toolbar-loop-block'
+import ParallelToolbarItem from './components/toolbar-parallel-block/toolbar-parallel-block'
+
+interface ToolbarProps {
+ userPermissions: WorkspaceUserPermissions
+ isWorkspaceSelectorVisible?: boolean
+}
+
+interface BlockItem {
+ name: string
+ type: string
+ isCustom: boolean
+ config?: any
+}
+
+export function Toolbar({ userPermissions, isWorkspaceSelectorVisible = false }: ToolbarProps) {
+ const [searchQuery, setSearchQuery] = useState('')
+
+ const { regularBlocks, specialBlocks, tools } = useMemo(() => {
+ const allBlocks = getAllBlocks()
+
+ // Filter blocks based on search query
+ const filteredBlocks = allBlocks.filter((block) => {
+ if (block.type === 'starter' || block.hideFromToolbar) return false
+
+ return (
+ !searchQuery.trim() ||
+ block.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ block.description.toLowerCase().includes(searchQuery.toLowerCase())
+ )
+ })
+
+ // Separate regular blocks (category: 'blocks') and tools (category: 'tools')
+ const regularBlockConfigs = filteredBlocks.filter((block) => block.category === 'blocks')
+ const toolConfigs = filteredBlocks.filter((block) => block.category === 'tools')
+
+ // Create regular block items and sort alphabetically
+ const regularBlockItems: BlockItem[] = regularBlockConfigs
+ .map((block) => ({
+ name: block.name,
+ type: block.type,
+ config: block,
+ isCustom: false,
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name))
+
+ // Create special blocks (loop and parallel) if they match search
+ const specialBlockItems: BlockItem[] = []
+
+ if (!searchQuery.trim() || 'loop'.toLowerCase().includes(searchQuery.toLowerCase())) {
+ specialBlockItems.push({
+ name: 'Loop',
+ type: 'loop',
+ isCustom: true,
+ })
+ }
+
+ if (!searchQuery.trim() || 'parallel'.toLowerCase().includes(searchQuery.toLowerCase())) {
+ specialBlockItems.push({
+ name: 'Parallel',
+ type: 'parallel',
+ isCustom: true,
+ })
+ }
+
+ // Sort special blocks alphabetically
+ specialBlockItems.sort((a, b) => a.name.localeCompare(b.name))
+
+ // Sort tools alphabetically
+ toolConfigs.sort((a, b) => a.name.localeCompare(b.name))
+
+ return {
+ regularBlocks: regularBlockItems,
+ specialBlocks: specialBlockItems,
+ tools: toolConfigs,
+ }
+ }, [searchQuery])
+
+ return (
+
+ {/* Search */}
+
+
+
+ setSearchQuery(e.target.value)}
+ className='h-6 flex-1 border-0 bg-transparent px-0 font-normal text-muted-foreground text-sm leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
+ autoComplete='off'
+ autoCorrect='off'
+ autoCapitalize='off'
+ spellCheck='false'
+ />
+
+
+
+ {/* Content */}
+
+
+ {/* Regular Blocks Section */}
+ {regularBlocks.map((block) => (
+
+ ))}
+
+ {/* Special Blocks Section (Loop & Parallel) */}
+ {specialBlocks.map((block) => {
+ if (block.type === 'loop') {
+ return
+ }
+ if (block.type === 'parallel') {
+ return
+ }
+ return null
+ })}
+
+ {/* Tools Section */}
+ {tools.map((tool) => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-context-menu/workflow-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-context-menu/workflow-context-menu.tsx
new file mode 100644
index 000000000..0f5a9b572
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-context-menu/workflow-context-menu.tsx
@@ -0,0 +1,139 @@
+'use client'
+
+import { useState } from 'react'
+import { MoreHorizontal, Pencil } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { createLogger } from '@/lib/logs/console-logger'
+import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
+import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
+import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
+
+const logger = createLogger('WorkflowContextMenu')
+
+interface WorkflowContextMenuProps {
+ workflow: WorkflowMetadata
+ onRename?: (workflowId: string, newName: string) => void
+ level: number
+}
+
+export function WorkflowContextMenu({ workflow, onRename, level }: WorkflowContextMenuProps) {
+ const [showRenameDialog, setShowRenameDialog] = useState(false)
+ const [renameName, setRenameName] = useState(workflow.name)
+ const [isRenaming, setIsRenaming] = useState(false)
+
+ // Get user permissions for the workspace
+ const userPermissions = useUserPermissionsContext()
+
+ const { updateWorkflow } = useWorkflowRegistry()
+
+ const handleRename = () => {
+ setRenameName(workflow.name)
+ setShowRenameDialog(true)
+ }
+
+ const handleRenameSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!renameName.trim()) return
+
+ setIsRenaming(true)
+ try {
+ if (onRename) {
+ onRename(workflow.id, renameName.trim())
+ } else {
+ // Default rename behavior using updateWorkflow
+ await updateWorkflow(workflow.id, { name: renameName.trim() })
+ logger.info(
+ `Successfully renamed workflow from "${workflow.name}" to "${renameName.trim()}"`
+ )
+ }
+ setShowRenameDialog(false)
+ } catch (error) {
+ logger.error('Failed to rename workflow:', {
+ error,
+ workflowId: workflow.id,
+ oldName: workflow.name,
+ newName: renameName.trim(),
+ })
+ } finally {
+ setIsRenaming(false)
+ }
+ }
+
+ const handleCancel = () => {
+ setRenameName(workflow.name)
+ setShowRenameDialog(false)
+ }
+
+ return (
+ <>
+
+
+
+
+ e.stopPropagation()}
+ className='min-w-32 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[#202020]'
+ >
+ {userPermissions.canEdit && (
+
+
+ Rename
+
+ )}
+
+
+
+ {/* Rename dialog */}
+
+ >
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx
index b9b608a89..5b7e8f505 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx
@@ -1,42 +1,20 @@
'use client'
-import React, { useCallback, useEffect, useMemo, useState } from 'react'
-import { ChevronDown, Pencil, Trash2, X } from 'lucide-react'
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { ChevronDown, ChevronUp, PanelLeft } from 'lucide-react'
import Link from 'next/link'
-import { useParams, useRouter } from 'next/navigation'
import { AgentIcon } from '@/components/icons'
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- AlertDialogTrigger,
-} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
-import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu'
-import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console-logger'
-import { cn } from '@/lib/utils'
-import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
-import { useSidebarStore } from '@/stores/sidebar/store'
-import { useSubscriptionStore } from '@/stores/subscription/store'
-import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('WorkspaceHeader')
+/**
+ * Workspace entity interface
+ */
interface Workspace {
id: string
name: string
@@ -44,698 +22,250 @@ interface Workspace {
role?: string
}
+/**
+ * Main WorkspaceHeader component props
+ */
interface WorkspaceHeaderProps {
onCreateWorkflow: () => void
- isCollapsed?: boolean
- onDropdownOpenChange?: (isOpen: boolean) => void
+ isWorkspaceSelectorVisible: boolean
+ onToggleWorkspaceSelector: () => void
+ activeWorkspace: Workspace | null
+ isWorkspacesLoading: boolean
+ updateWorkspaceName: (workspaceId: string, newName: string) => Promise
}
-// New WorkspaceModal component
-interface WorkspaceModalProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- onCreateWorkspace: (name: string) => void
-}
-
-const WorkspaceModal = React.memo(
- ({ open, onOpenChange, onCreateWorkspace }) => {
- const [workspaceName, setWorkspaceName] = useState('')
-
- const handleSubmit = useCallback(
- (e: React.FormEvent) => {
- e.preventDefault()
- if (workspaceName.trim()) {
- onCreateWorkspace(workspaceName.trim())
- setWorkspaceName('')
- onOpenChange(false)
- }
- },
- [workspaceName, onCreateWorkspace, onOpenChange]
- )
-
- const handleNameChange = useCallback((e: React.ChangeEvent) => {
- setWorkspaceName(e.target.value)
- }, [])
-
- const handleClose = useCallback(() => {
- onOpenChange(false)
- }, [onOpenChange])
-
- return (
-
- )
- }
-)
-
-WorkspaceModal.displayName = 'WorkspaceModal'
-
-// New WorkspaceEditModal component
-interface WorkspaceEditModalProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- onUpdateWorkspace: (id: string, name: string) => void
- workspace: Workspace | null
-}
-
-const WorkspaceEditModal = React.memo(
- ({ open, onOpenChange, onUpdateWorkspace, workspace }) => {
- const [workspaceName, setWorkspaceName] = useState('')
-
- useEffect(() => {
- if (workspace && open) {
- setWorkspaceName(workspace.name)
- }
- }, [workspace, open])
-
- const handleSubmit = useCallback(
- (e: React.FormEvent) => {
- e.preventDefault()
- if (workspace && workspaceName.trim()) {
- onUpdateWorkspace(workspace.id, workspaceName.trim())
- setWorkspaceName('')
- onOpenChange(false)
- }
- },
- [workspace, workspaceName, onUpdateWorkspace, onOpenChange]
- )
-
- const handleNameChange = useCallback((e: React.ChangeEvent) => {
- setWorkspaceName(e.target.value)
- }, [])
-
- const handleClose = useCallback(() => {
- onOpenChange(false)
- }, [onOpenChange])
-
- return (
-
- )
- }
-)
-
-WorkspaceEditModal.displayName = 'WorkspaceEditModal'
-
+/**
+ * WorkspaceHeader component - Single row header with all elements
+ */
export const WorkspaceHeader = React.memo(
- ({ onCreateWorkflow, isCollapsed, onDropdownOpenChange }) => {
- // Get sidebar store state to check current mode
- const { mode, workspaceDropdownOpen, setWorkspaceDropdownOpen, setAnyModalOpen } =
- useSidebarStore()
-
- const { data: sessionData, isPending } = useSession()
-
- const { getSubscriptionStatus } = useSubscriptionStore()
- const subscription = getSubscriptionStatus()
-
- const getPlanName = (subscription: ReturnType) => {
- if (subscription.isEnterprise) return 'Enterprise Plan'
- if (subscription.isTeam) return 'Team Plan'
- if (subscription.isPro) return 'Pro Plan'
- return 'Free Plan'
- }
-
- const plan = getPlanName(subscription)
-
- // Use client-side loading instead of isPending to avoid hydration mismatch
+ ({
+ onCreateWorkflow,
+ isWorkspaceSelectorVisible,
+ onToggleWorkspaceSelector,
+ activeWorkspace,
+ isWorkspacesLoading,
+ updateWorkspaceName,
+ }) => {
+ // External hooks
+ const { data: sessionData } = useSession()
const [isClientLoading, setIsClientLoading] = useState(true)
- const [workspaces, setWorkspaces] = useState([])
- const [activeWorkspace, setActiveWorkspace] = useState(null)
- const [isWorkspacesLoading, setIsWorkspacesLoading] = useState(true)
- const [isWorkspaceModalOpen, setIsWorkspaceModalOpen] = useState(false)
- const [editingWorkspace, setEditingWorkspace] = useState(null)
- const [isEditModalOpen, setIsEditModalOpen] = useState(false)
- const [isDeleting, setIsDeleting] = useState(false)
- const router = useRouter()
+ const [isEditingName, setIsEditingName] = useState(false)
+ const [editingName, setEditingName] = useState('')
- // Get workflowRegistry state and actions
- const { switchToWorkspace } = useWorkflowRegistry()
- const params = useParams()
- const currentWorkspaceId = params.workspaceId as string
-
- // Get user permissions for the active workspace
- const userPermissions = useUserPermissionsContext()
+ // Refs
+ const editInputRef = useRef(null)
+ // Computed values
const userName = useMemo(
() => sessionData?.user?.name || sessionData?.user?.email || 'User',
[sessionData?.user?.name, sessionData?.user?.email]
)
-
- // Set isClientLoading to false after hydration
- useEffect(() => {
- setIsClientLoading(false)
- }, [])
-
- const fetchWorkspaces = useCallback(async () => {
- setIsWorkspacesLoading(true)
- try {
- const response = await fetch('/api/workspaces')
- const data = await response.json()
-
- if (data.workspaces && Array.isArray(data.workspaces)) {
- const fetchedWorkspaces = data.workspaces as Workspace[]
- setWorkspaces(fetchedWorkspaces)
-
- // Only update workspace if we have a valid currentWorkspaceId from URL
- if (currentWorkspaceId) {
- const matchingWorkspace = fetchedWorkspaces.find(
- (workspace) => workspace.id === currentWorkspaceId
- )
- if (matchingWorkspace) {
- setActiveWorkspace(matchingWorkspace)
- } else {
- // Log the mismatch for debugging
- logger.warn(`Workspace ${currentWorkspaceId} not found in user's workspaces`)
-
- // Current workspace not found, fallback to first workspace
- if (fetchedWorkspaces.length > 0) {
- const fallbackWorkspace = fetchedWorkspaces[0]
- setActiveWorkspace(fallbackWorkspace)
- // Navigate to the fallback workspace
- router.push(`/workspace/${fallbackWorkspace.id}/w`)
- } else {
- // No workspaces available - handle this edge case
- logger.error('No workspaces available for user')
- }
- }
- }
- }
- } catch (err) {
- logger.error('Error fetching workspaces:', err)
- } finally {
- setIsWorkspacesLoading(false)
- }
- }, [currentWorkspaceId, router])
-
- useEffect(() => {
- // Fetch subscription status if user is logged in
- if (sessionData?.user?.id) {
- fetchWorkspaces()
- }
- }, [sessionData?.user?.id, fetchWorkspaces])
-
- const switchWorkspace = useCallback(
- async (workspace: Workspace) => {
- // If already on this workspace, close dropdown and do nothing else
- if (activeWorkspace?.id === workspace.id) {
- setWorkspaceDropdownOpen(false)
- return
- }
-
- setActiveWorkspace(workspace)
- setWorkspaceDropdownOpen(false)
-
- // Use full workspace switch which now handles localStorage automatically
- await switchToWorkspace(workspace.id)
-
- // Update URL to include workspace ID - only after workspace switch completes
- router.push(`/workspace/${workspace.id}/w`)
- },
- [activeWorkspace?.id, switchToWorkspace, router, setWorkspaceDropdownOpen]
- )
-
- const handleCreateWorkspace = useCallback(
- async (name: string) => {
- setIsWorkspacesLoading(true)
-
- try {
- const response = await fetch('/api/workspaces', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ name }),
- })
-
- const data = await response.json()
-
- if (data.workspace) {
- const newWorkspace = data.workspace as Workspace
- setWorkspaces((prev) => [...prev, newWorkspace])
- setActiveWorkspace(newWorkspace)
-
- // Use switchToWorkspace to properly load workflows for the new workspace
- // This will clear existing workflows, set loading state, and fetch workflows from DB
- await switchToWorkspace(newWorkspace.id)
-
- // Update URL to include new workspace ID - only after workspace switch completes
- router.push(`/workspace/${newWorkspace.id}/w`)
- }
- } catch (err) {
- logger.error('Error creating workspace:', err)
- } finally {
- setIsWorkspacesLoading(false)
- }
- },
- [switchToWorkspace, router]
- )
-
- const handleUpdateWorkspace = useCallback(
- async (id: string, name: string) => {
- // For update operations, we need to check permissions for the specific workspace
- // Since we can only use hooks at the component level, we'll make the API call
- // and let the backend handle the permission check
- setIsWorkspacesLoading(true)
-
- try {
- const response = await fetch(`/api/workspaces/${id}`, {
- method: 'PATCH',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ name }),
- })
-
- if (!response.ok) {
- if (response.status === 403) {
- logger.error(
- 'Permission denied: Only users with admin permissions can update workspaces'
- )
- }
- throw new Error('Failed to update workspace')
- }
-
- const { workspace: updatedWorkspace } = await response.json()
-
- // Update workspaces list
- setWorkspaces((prevWorkspaces) =>
- prevWorkspaces.map((w) =>
- w.id === updatedWorkspace.id ? { ...w, name: updatedWorkspace.name } : w
- )
- )
-
- // If active workspace was updated, update it too
- if (activeWorkspace && activeWorkspace.id === updatedWorkspace.id) {
- setActiveWorkspace({
- ...activeWorkspace,
- name: updatedWorkspace.name,
- })
- }
- } catch (err) {
- logger.error('Error updating workspace:', err)
- } finally {
- setIsWorkspacesLoading(false)
- }
- },
- [activeWorkspace]
- )
-
- const handleDeleteWorkspace = useCallback(
- async (id: string) => {
- // For delete operations, we need to check permissions for the specific workspace
- // Since we can only use hooks at the component level, we'll make the API call
- // and let the backend handle the permission check
- setIsDeleting(true)
-
- try {
- const response = await fetch(`/api/workspaces/${id}`, {
- method: 'DELETE',
- })
-
- if (!response.ok) {
- if (response.status === 403) {
- logger.error(
- 'Permission denied: Only users with admin permissions can delete workspaces'
- )
- }
- throw new Error('Failed to delete workspace')
- }
-
- // Remove from workspace list
- const updatedWorkspaces = workspaces.filter((w) => w.id !== id)
- setWorkspaces(updatedWorkspaces)
-
- // If deleted workspace was active, switch to another workspace
- if (activeWorkspace?.id === id) {
- const newWorkspace = updatedWorkspaces[0]
- setActiveWorkspace(newWorkspace)
-
- // Switch to the new workspace (this handles all workflow state management)
- await switchToWorkspace(newWorkspace.id)
-
- // Navigate to the new workspace - only after workspace switch completes
- router.push(`/workspace/${newWorkspace.id}/w`)
- }
-
- setWorkspaceDropdownOpen(false)
- } catch (err) {
- logger.error('Error deleting workspace:', err)
- } finally {
- setIsDeleting(false)
- }
- },
- [workspaces, activeWorkspace?.id]
- )
-
- const openEditModal = useCallback(
- (workspace: Workspace, e: React.MouseEvent) => {
- e.stopPropagation()
- // Only show edit/delete options for the active workspace if user has admin permissions
- if (activeWorkspace?.id !== workspace.id || !userPermissions.canAdmin) {
- return
- }
- setEditingWorkspace(workspace)
- setIsEditModalOpen(true)
- },
- [activeWorkspace?.id, userPermissions.canAdmin]
- )
-
- // Determine URL for workspace links
const workspaceUrl = useMemo(
() => (activeWorkspace ? `/workspace/${activeWorkspace.id}/w` : '/workspace'),
[activeWorkspace]
)
- // Notify parent component when dropdown opens/closes
- const handleDropdownOpenChange = useCallback(
- (open: boolean) => {
- setWorkspaceDropdownOpen(open)
- // Inform the parent component about the dropdown state change
- if (onDropdownOpenChange) {
- onDropdownOpenChange(open)
- }
- },
- [onDropdownOpenChange, setWorkspaceDropdownOpen]
+ const displayName = useMemo(
+ () => activeWorkspace?.name || `${userName}'s Workspace`,
+ [activeWorkspace?.name, userName]
)
- // Special handling for click interactions in hover mode
- const handleTriggerClick = useCallback(
- (e: React.MouseEvent) => {
- // When in hover mode, explicitly prevent bubbling for the trigger
- if (mode === 'hover') {
- e.stopPropagation()
- e.preventDefault()
- // Toggle dropdown state
- handleDropdownOpenChange(!workspaceDropdownOpen)
- }
- },
- [mode, workspaceDropdownOpen, handleDropdownOpenChange]
- )
-
- const handleContainerClick = useCallback(
- (e: React.MouseEvent) => {
- // In hover mode, prevent clicks on the container from collapsing the sidebar
- if (mode === 'hover') {
- e.stopPropagation()
- }
- },
- [mode]
- )
-
- const handleWorkspaceModalOpenChange = useCallback((open: boolean) => {
- setIsWorkspaceModalOpen(open)
- }, [])
-
- const handleEditModalOpenChange = useCallback((open: boolean) => {
- setIsEditModalOpen(open)
- }, [])
-
- // Handle modal open/close state
+ // Effects
useEffect(() => {
- // Update the modal state in the store
- setAnyModalOpen(isWorkspaceModalOpen || isEditModalOpen || isDeleting)
- }, [isWorkspaceModalOpen, isEditModalOpen, isDeleting, setAnyModalOpen])
+ setIsClientLoading(false)
+ }, [])
- return (
-
- {/* Workspace Modal */}
-
+ // Focus input when editing starts
+ useEffect(() => {
+ if (isEditingName && editInputRef.current) {
+ editInputRef.current.focus()
+ editInputRef.current.select()
+ }
+ }, [isEditingName])
- {/* Edit Workspace Modal */}
-
+ // Handle toggle sidebar
+ const handleToggleSidebar = useCallback(() => {
+ // This will be implemented when needed - placeholder for now
+ logger.info('Toggle sidebar clicked')
+ }, [])
-
- {
+ setEditingName(displayName)
+ setIsEditingName(true)
+ }, [displayName])
+
+ // Handle workspace name editing actions
+ const handleEditingAction = useCallback(
+ (action: 'save' | 'cancel') => {
+ switch (action) {
+ case 'save': {
+ // Exit edit mode immediately, save in background
+ setIsEditingName(false)
+ if (activeWorkspace && editingName.trim() !== '') {
+ updateWorkspaceName(activeWorkspace.id, editingName.trim()).catch((error) => {
+ logger.error('Failed to update workspace name:', error)
+ })
+ }
+ break
+ }
+
+ case 'cancel': {
+ // Cancel without saving
+ setIsEditingName(false)
+ setEditingName('')
+ break
+ }
+ }
+ },
+ [activeWorkspace, editingName, updateWorkspaceName]
+ )
+
+ // Handle keyboard interactions
+ const handleInputKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ handleEditingAction('save')
+ } else if (e.key === 'Escape') {
+ handleEditingAction('cancel')
+ }
+ },
+ [handleEditingAction]
+ )
+
+ // Handle click away - immediate exit with background save
+ const handleInputBlur = useCallback(() => {
+ handleEditingAction('save')
+ }, [handleEditingAction])
+
+ // Render loading state
+ const renderLoadingState = () => (
+ <>
+ {/* Icon */}
+
+
+ {/* Loading workspace name and chevron container */}
+
+
+
+
+
+ {/* Chevron */}
+