feat(workflow): workflow overhaul (#1906)

* feat: action-bar side-effects; refactor: removed unused styles from globals and tailwind config

* feat(terminal): filters/sorting; fix(workflow): zoom bug

* feat(sidebar): toggle; feat(terminal): show/hide timestamp

* feat(toolbar): triggers ordering

* feat: commands, cursors, room-presence, command-list, rename blocks, workspace controls, invite, search modal

* removed old imports

* ack PR comments

* fix tag dropdown

* feat: variables UI; fix: terminal keys

---------

Co-authored-by: waleed <waleed>
This commit is contained in:
Emir Karabeg
2025-11-11 16:30:30 -08:00
committed by GitHub
parent b093550656
commit 7b48d6ed53
51 changed files with 3858 additions and 2286 deletions

View File

@@ -11,7 +11,7 @@
--panel-width: 244px;
--toolbar-triggers-height: 300px;
--editor-connections-height: 200px;
--terminal-height: 100px;
--terminal-height: 145px;
}
.sidebar-container {
@@ -260,11 +260,6 @@
/**
* Dark mode specific overrides
*/
.dark .error-badge {
background-color: hsl(0, 70%, 20%) !important;
color: hsl(0, 0%, 100%) !important;
}
.dark .bg-red-500 {
@apply bg-red-700;
}
@@ -285,23 +280,11 @@ input[type="search"]::-ms-clear {
display: none;
}
/**
* Layout utilities
*/
.main-content-overlay {
z-index: 40;
}
/**
* Utilities and special effects
* Animation keyframes are defined in tailwind.config.ts
*/
@layer utilities {
.animation-container {
contain: paint layout style;
will-change: opacity, transform;
}
.scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
@@ -348,46 +331,6 @@ input[type="search"]::-ms-clear {
background-color: hsl(var(--input-background));
}
.bg-brand-primary {
background-color: var(--brand-primary-hex);
}
.bg-brand-primary-hover {
background-color: var(--brand-primary-hover-hex);
}
.hover\:bg-brand-primary-hover:hover {
background-color: var(--brand-primary-hover-hex);
}
.hover\:text-brand-accent-hover:hover {
color: var(--brand-accent-hover-hex);
}
.bg-brand-gradient {
background: linear-gradient(
to bottom,
color-mix(in srgb, var(--brand-primary-hex) 85%, white),
var(--brand-primary-hex)
);
}
.border-brand-gradient {
border-color: var(--brand-primary-hex);
}
.shadow-brand-gradient {
box-shadow: inset 0 2px 4px 0 color-mix(in srgb, var(--brand-primary-hex) 60%, transparent);
}
.hover\:bg-brand-gradient-hover:hover {
background: linear-gradient(
to bottom,
var(--brand-primary-hover-hex),
color-mix(in srgb, var(--brand-primary-hex) 90%, black)
);
}
.auth-card {
background-color: rgba(255, 255, 255, 0.9) !important;
border-color: #e5e5e5 !important;
@@ -419,10 +362,6 @@ input[type="search"]::-ms-clear {
color: #737373 !important;
}
.bg-surface-elevated {
background-color: var(--surface-elevated);
}
.transition-ring {
transition-property: box-shadow, transform;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);

View File

@@ -0,0 +1,218 @@
'use client'
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
} from 'react'
import { useRouter } from 'next/navigation'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('GlobalCommands')
/**
* Detects if the current platform is macOS.
*
* @returns True if running on macOS, false otherwise
*/
function isMacPlatform(): boolean {
if (typeof window === 'undefined') return false
return (
/Mac|iPhone|iPod|iPad/i.test(navigator.platform) ||
/Mac|iPhone|iPod|iPad/i.test(navigator.userAgent)
)
}
/**
* Represents a parsed keyboard shortcut.
*
* We support the following modifiers:
* - Mod: maps to Meta on macOS, Ctrl on other platforms
* - Ctrl, Meta, Shift, Alt
*
* Examples:
* - "Mod+A"
* - "Mod+Shift+T"
* - "Meta+K"
*/
export interface ParsedShortcut {
key: string
mod?: boolean
ctrl?: boolean
meta?: boolean
shift?: boolean
alt?: boolean
}
/**
* Declarative command registration.
*/
export interface GlobalCommand {
/** Unique id for the command. If omitted, one is generated. */
id?: string
/** Shortcut string in the form "Mod+Shift+T", "Mod+A", "Meta+K", etc. */
shortcut: string
/**
* Whether to allow the command to run inside editable elements like inputs,
* textareas or contenteditable. Defaults to true to ensure browser defaults
* are overridden when desired.
*/
allowInEditable?: boolean
/**
* Handler invoked when the shortcut is matched. Use this to trigger actions
* like navigation or dispatching application events.
*/
handler: (event: KeyboardEvent) => void
}
interface RegistryCommand extends GlobalCommand {
id: string
parsed: ParsedShortcut
}
interface GlobalCommandsContextValue {
register: (commands: GlobalCommand[]) => () => void
}
const GlobalCommandsContext = createContext<GlobalCommandsContextValue | null>(null)
/**
* Parses a human-readable shortcut into a structured representation.
*/
function parseShortcut(shortcut: string): ParsedShortcut {
const parts = shortcut.split('+').map((p) => p.trim())
const modifiers = new Set(parts.slice(0, -1).map((p) => p.toLowerCase()))
const last = parts[parts.length - 1]
return {
key: last.length === 1 ? last.toLowerCase() : last, // keep non-letter keys verbatim
mod: modifiers.has('mod'),
ctrl: modifiers.has('ctrl'),
meta: modifiers.has('meta') || modifiers.has('cmd') || modifiers.has('command'),
shift: modifiers.has('shift'),
alt: modifiers.has('alt') || modifiers.has('option'),
}
}
/**
* Checks if a KeyboardEvent matches a parsed shortcut, honoring platform-specific
* interpretation of "Mod" (Meta on macOS, Ctrl elsewhere).
*/
function matchesShortcut(e: KeyboardEvent, parsed: ParsedShortcut): boolean {
const isMac = isMacPlatform()
const expectedCtrl = parsed.ctrl || (parsed.mod ? !isMac : false)
const expectedMeta = parsed.meta || (parsed.mod ? isMac : false)
// Normalize key for comparison: for letters compare lowercase
const eventKey = e.key.length === 1 ? e.key.toLowerCase() : e.key
return (
eventKey === parsed.key &&
!!e.ctrlKey === !!expectedCtrl &&
!!e.metaKey === !!expectedMeta &&
!!e.shiftKey === !!parsed.shift &&
!!e.altKey === !!parsed.alt
)
}
/**
* Provider that captures global keyboard shortcuts and routes them to
* registered commands. Commands can be registered from any descendant component.
*/
export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
const registryRef = useRef<Map<string, RegistryCommand>>(new Map())
const isMac = useMemo(() => isMacPlatform(), [])
const router = useRouter()
const register = useCallback((commands: GlobalCommand[]) => {
const createdIds: string[] = []
for (const cmd of commands) {
const id = cmd.id ?? crypto.randomUUID()
const parsed = parseShortcut(cmd.shortcut)
registryRef.current.set(id, {
...cmd,
id,
parsed,
allowInEditable: cmd.allowInEditable ?? true,
})
createdIds.push(id)
logger.info('Registered global command', { id, shortcut: cmd.shortcut })
}
return () => {
for (const id of createdIds) {
registryRef.current.delete(id)
logger.info('Unregistered global command', { id })
}
}
}, [])
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.isComposing) return
// Evaluate matches in registration order (latest registration wins naturally
// due to replacement on same id). Break on first match.
for (const [, cmd] of registryRef.current) {
if (!cmd.allowInEditable) {
const ae = document.activeElement
const isEditable =
ae instanceof HTMLInputElement ||
ae instanceof HTMLTextAreaElement ||
ae?.hasAttribute('contenteditable')
if (isEditable) continue
}
if (matchesShortcut(e, cmd.parsed)) {
// Always override default browser behavior for matched commands.
e.preventDefault()
e.stopPropagation()
logger.info('Executing global command', {
id: cmd.id,
shortcut: cmd.shortcut,
key: e.key,
isMac,
path: typeof window !== 'undefined' ? window.location.pathname : undefined,
})
try {
cmd.handler(e)
} catch (err) {
logger.error('Global command handler threw', { id: cmd.id, err })
}
return
}
}
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [isMac, router])
const value = useMemo<GlobalCommandsContextValue>(() => ({ register }), [register])
return <GlobalCommandsContext.Provider value={value}>{children}</GlobalCommandsContext.Provider>
}
/**
* Registers a set of global commands for the lifetime of the component.
*
* Returns nothing; cleanup is automatic on unmount.
*/
export function useRegisterGlobalCommands(commands: GlobalCommand[] | (() => GlobalCommand[])) {
const ctx = useContext(GlobalCommandsContext)
if (!ctx) {
throw new Error('useRegisterGlobalCommands must be used within GlobalCommandsProvider')
}
useEffect(() => {
const list = typeof commands === 'function' ? commands() : commands
const unregister = ctx.register(list)
return unregister
// We intentionally want to register once for the given commands
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}

View File

@@ -2,6 +2,7 @@
import React from 'react'
import { Tooltip } from '@/components/emcn'
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { SettingsLoader } from './settings-loader'
@@ -13,9 +14,11 @@ const Providers = React.memo<ProvidersProps>(({ children }) => {
return (
<>
<SettingsLoader />
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
<WorkspacePermissionsProvider>{children}</WorkspacePermissionsProvider>
</Tooltip.Provider>
<GlobalCommandsProvider>
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
<WorkspacePermissionsProvider>{children}</WorkspacePermissionsProvider>
</Tooltip.Provider>
</GlobalCommandsProvider>
</>
)
})

View File

@@ -0,0 +1,115 @@
'use client'
import { Layout, LibraryBig, Search } from 'lucide-react'
import Image from 'next/image'
import { Button } from '@/components/emcn'
import { AgentIcon } from '@/components/icons'
import { cn } from '@/lib/utils'
/**
* Command item data structure
*/
interface CommandItem {
/** Display label for the command */
label: string
/** Icon component from lucide-react */
icon: React.ComponentType<{ className?: string }>
/** Keyboard shortcut keys (can be single or array for multiple keys) */
shortcut: string | string[]
}
/**
* Available commands list
*/
const commands: CommandItem[] = [
{
label: 'Templates',
icon: Layout,
shortcut: 'Y',
},
{
label: 'New Agent',
icon: AgentIcon,
shortcut: ['⇧', 'A'],
},
{
label: 'Logs',
icon: LibraryBig,
shortcut: 'L',
},
{
label: 'Search Blocks',
icon: Search,
shortcut: 'K',
},
]
/**
* CommandList component that displays available commands with keyboard shortcuts
* Centered on the screen for empty workflows
*/
export function CommandList() {
return (
<div
className={cn(
'pointer-events-none absolute inset-0 mb-[50px] flex items-center justify-center'
)}
>
<div className='pointer-events-none flex flex-col gap-[8px]'>
{/* Logo */}
<div className='mb-[20px] flex justify-center'>
<Image
src='/logo/b&w/text/b&w.svg'
alt='Sim'
width={99.56}
height={48.56}
className='opacity-70'
style={{
filter:
'brightness(0) saturate(100%) invert(69%) sepia(0%) saturate(0%) hue-rotate(202deg) brightness(94%) contrast(89%)',
}}
priority
/>
</div>
{commands.map((command) => {
const Icon = command.icon
const shortcuts = Array.isArray(command.shortcut) ? command.shortcut : [command.shortcut]
return (
<div
key={command.label}
className='group flex cursor-pointer items-center justify-between gap-[60px]'
>
{/* Left side: Icon and Label */}
<div className='flex items-center gap-[8px]'>
<Icon className='h-[14px] w-[14px] text-[#AEAEAE] group-hover:text-[#E6E6E6] dark:group-hover:text-[#E6E6E6]' />
<span className='font-medium text-[#AEAEAE] text-[14px] group-hover:text-[#E6E6E6] dark:group-hover:text-[#E6E6E6]'>
{command.label}
</span>
</div>
{/* Right side: Keyboard Shortcut */}
<div className='flex items-center gap-[4px]'>
<Button
className='group-hover:-translate-y-0.5 w-[26px] py-[3px] text-[12px] hover:translate-y-0 hover:text-[#AEAEAE] hover:shadow-[0_2px_0_0] group-hover:text-[#E6E6E6] group-hover:shadow-[0_4px_0_0] group-hover:dark:text-[#E6E6E6] group-hover:dark:shadow-[#303030] hover:dark:text-[#AEAEAE] hover:dark:shadow-[#303030]'
variant='3d'
>
<span></span>
</Button>
{shortcuts.map((key, index) => (
<Button
key={index}
className='group-hover:-translate-y-0.5 w-[26px] py-[3px] text-[12px] hover:translate-y-0 hover:text-[#AEAEAE] hover:shadow-[0_2px_0_0] group-hover:text-[#E6E6E6] group-hover:shadow-[0_4px_0_0] group-hover:dark:text-[#E6E6E6] group-hover:dark:shadow-[#303030] hover:dark:text-[#AEAEAE] hover:dark:shadow-[#303030]'
variant='3d'
>
{key}
</Button>
))}
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -39,10 +39,6 @@ import {
WebhookSettings,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components'
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
import {
getKeyboardShortcutText,
useKeyboardShortcuts,
} from '@/app/workspace/[workspaceId]/w/hooks/use-keyboard-shortcuts'
import { useDebounce } from '@/hooks/use-debounce'
import { useFolderStore } from '@/stores/folders/store'
import { useOperationQueueStore } from '@/stores/operation-queue/store'
@@ -147,14 +143,6 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
// Shared condition for keyboard shortcut and button disabled state
const isWorkflowBlocked = isExecuting || hasValidationErrors
// Register keyboard shortcut for running workflow
useKeyboardShortcuts(() => {
if (!isWorkflowBlocked) {
openConsolePanel()
handleRunWorkflow()
}
}, isWorkflowBlocked)
// // Check if the current user is the owner of the published workflow
// const isWorkflowOwner = () => {
// const marketplaceData = getMarketplaceData()
@@ -1125,12 +1113,6 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
<Play className={cn('h-3.5 w-3.5', 'fill-current stroke-current')} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
{getTooltipContent()}
<span className='ml-1 text-xs opacity-75'>
({getKeyboardShortcutText('Enter', true)})
</span>
</Tooltip.Content>
</Tooltip.Root>
)
}

View File

@@ -0,0 +1,93 @@
'use client'
import { memo, useMemo } from 'react'
import { useViewport } from 'reactflow'
import { useSession } from '@/lib/auth-client'
import { getUserColor } from '@/app/workspace/[workspaceId]/w/utils/get-user-color'
import { useSocket } from '@/contexts/socket-context'
interface CursorPoint {
x: number
y: number
}
interface CursorRenderData {
id: string
name: string
cursor: CursorPoint
color: string
}
const POINTER_OFFSET = {
x: 0,
y: 0,
}
const CursorsComponent = () => {
const { presenceUsers } = useSocket()
const viewport = useViewport()
const session = useSession()
const currentUserId = session.data?.user?.id
const cursors = useMemo<CursorRenderData[]>(() => {
return presenceUsers
.filter((user): user is typeof user & { cursor: CursorPoint } => Boolean(user.cursor))
.filter((user) => user.userId !== currentUserId)
.map((user) => ({
id: user.socketId,
name: user.userName?.trim() || 'Collaborator',
cursor: user.cursor,
color: getUserColor(user.userId),
}))
}, [currentUserId, presenceUsers])
if (!cursors.length) {
return null
}
return (
<div className='pointer-events-none absolute inset-0 z-30 select-none'>
{cursors.map(({ id, name, cursor, color }) => {
const x = cursor.x * viewport.zoom + viewport.x
const y = cursor.y * viewport.zoom + viewport.y
return (
<div
key={id}
className='pointer-events-none absolute'
style={{
transform: `translate3d(${x}px, ${y}px, 0)`,
transition: 'transform 0.12s ease-out',
}}
>
<div
className='relative'
style={{ transform: `translate(${-POINTER_OFFSET.x}px, ${-POINTER_OFFSET.y}px)` }}
>
{/* Simple cursor pointer */}
<svg width={16} height={18} viewBox='0 0 16 18' fill='none'>
<path
d='M0.5 0.5L0.5 12L4 9L6.5 15L8.5 14L6 8L12 8L0.5 0.5Z'
fill={color}
stroke='rgba(0,0,0,0.3)'
strokeWidth={1}
/>
</svg>
{/* Name tag underneath and to the right */}
<div
className='absolute top-[18px] left-[4px] h-[21px] w-[140px] truncate whitespace-nowrap rounded-[2px] p-[6px] font-medium text-[#1E1E1E] text-[11px]'
style={{ backgroundColor: color }}
>
{name}
</div>
</div>
</div>
)
})}
</div>
)
}
export const Cursors = memo(CursorsComponent)
Cursors.displayName = 'Cursors'

View File

@@ -1,9 +1,13 @@
export { CommandList } from './command-list/command-list'
export { ControlBar } from './control-bar/control-bar'
export { Cursors } from './cursors/cursors'
export { DiffControls } from './diff-controls/diff-controls'
export { ErrorBoundary } from './error/index'
export { Panel } from './panel/panel'
export { Panel } from './panel-new/panel-new'
export { SkeletonLoading } from './skeleton-loading/skeleton-loading'
export { SubflowNodeComponent } from './subflows/subflow-node'
export { Terminal } from './terminal/terminal'
export { TrainingControls } from './training-controls/training-controls'
export { WandPromptBar } from './wand-prompt-bar/wand-prompt-bar'
export { WorkflowBlock } from './workflow-block/workflow-block'
export { WorkflowEdge } from './workflow-edge/workflow-edge'

View File

@@ -95,29 +95,18 @@ export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps>
useEffect(() => {
if (!visible || !flatTagList.length) return
// Helper to open a folder with proper selection callback and parent selection
const openFolderWithSelection = (
folderId: string,
folderTitle: string,
parentTag: string,
group: BlockTagGroup
) => {
const parentIdx = flatTagList.findIndex((item) => item.tag === parentTag)
const selectionCallback = () => handleTagSelect(parentTag, group)
// Find parent tag index (which is first in visible items when in folder)
let parentIndex = 0
for (const g of nestedBlockTagGroups) {
for (const nestedTag of g.nestedTags) {
if (nestedTag.parentTag === parentTag) {
const idx = flatTagList.findIndex((item) => item.tag === nestedTag.parentTag)
parentIndex = idx >= 0 ? idx : 0
break
}
}
}
openFolder(folderId, folderTitle, undefined, selectionCallback)
setSelectedIndex(parentIndex)
if (parentIdx >= 0) {
setSelectedIndex(parentIdx)
}
}
const handleKeyboardEvent = (e: KeyboardEvent) => {

View File

@@ -1,7 +1,16 @@
'use client'
import { useCallback, useRef } from 'react'
import { BookOpen, ChevronUp, Crosshair, RepeatIcon, Settings, SplitIcon } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
BookOpen,
Check,
ChevronUp,
Crosshair,
Pencil,
RepeatIcon,
Settings,
SplitIcon,
} from 'lucide-react'
import { Button, Tooltip } from '@/components/emcn'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
@@ -98,7 +107,13 @@ export function Editor() {
})
// Collaborative actions
const { collaborativeToggleBlockAdvancedMode } = useCollaborativeWorkflow()
const { collaborativeToggleBlockAdvancedMode, collaborativeUpdateBlockName } =
useCollaborativeWorkflow()
// Rename state
const [isRenaming, setIsRenaming] = useState(false)
const [editedName, setEditedName] = useState('')
const nameInputRef = useRef<HTMLInputElement>(null)
// Mode toggle handlers
const handleToggleAdvancedMode = useCallback(() => {
@@ -107,6 +122,43 @@ export function Editor() {
}
}, [currentBlockId, userPermissions.canEdit, collaborativeToggleBlockAdvancedMode])
/**
* Handles starting the rename process.
*/
const handleStartRename = useCallback(() => {
if (!userPermissions.canEdit || !currentBlock) return
setEditedName(currentBlock.name || '')
setIsRenaming(true)
}, [userPermissions.canEdit, currentBlock])
/**
* Handles saving the renamed block.
*/
const handleSaveRename = useCallback(() => {
if (!currentBlockId || !isRenaming) return
const trimmedName = editedName.trim()
if (trimmedName && trimmedName !== currentBlock?.name) {
collaborativeUpdateBlockName(currentBlockId, trimmedName)
}
setIsRenaming(false)
}, [currentBlockId, isRenaming, editedName, currentBlock?.name, collaborativeUpdateBlockName])
/**
* Handles canceling the rename process.
*/
const handleCancelRename = useCallback(() => {
setIsRenaming(false)
setEditedName('')
}, [])
// Focus input when entering rename mode
useEffect(() => {
if (isRenaming && nameInputRef.current) {
nameInputRef.current.select()
}
}, [isRenaming])
// Focus on block handler
const handleFocusOnBlock = useCallback(() => {
if (currentBlockId) {
@@ -145,14 +197,55 @@ export function Editor() {
/>
</div>
)}
<h2
className='min-w-0 flex-1 truncate pr-[8px] font-medium text-[#FFFFFF] text-[14px] dark:text-[#FFFFFF]'
title={title}
>
{title}
</h2>
{isRenaming ? (
<input
ref={nameInputRef}
type='text'
value={editedName}
onChange={(e) => setEditedName(e.target.value)}
onBlur={handleSaveRename}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSaveRename()
} else if (e.key === 'Escape') {
handleCancelRename()
}
}}
className='min-w-0 flex-1 truncate bg-transparent pr-[8px] font-medium text-[#FFFFFF] text-[14px] outline-none dark:text-[#FFFFFF]'
/>
) : (
<h2
className='min-w-0 flex-1 truncate pr-[8px] font-medium text-[#FFFFFF] text-[14px] dark:text-[#FFFFFF]'
title={title}
>
{title}
</h2>
)}
</div>
<div className='flex shrink-0 items-center gap-[8px]'>
{/* Rename button */}
{currentBlock && !isSubflow && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='p-0'
onClick={isRenaming ? handleSaveRename : handleStartRename}
disabled={!userPermissions.canEdit}
aria-label={isRenaming ? 'Save name' : 'Rename block'}
>
{isRenaming ? (
<Check className='h-[14px] w-[14px]' />
) : (
<Pencil className='h-[14px] w-[14px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>{isRenaming ? 'Save name' : 'Rename block'}</p>
</Tooltip.Content>
</Tooltip.Root>
)}
{/* Focus on block button */}
{currentBlock && (
<Tooltip.Root>

View File

@@ -30,12 +30,26 @@ interface BlockItem {
let cachedTriggers: ReturnType<typeof getTriggersForSidebar> | null = null
/**
* Gets triggers data, computing it once and caching for subsequent calls
* Gets triggers data, computing it once and caching for subsequent calls.
* Non-integration triggers (Start, Schedule, Webhook) are prioritized first,
* followed by all other triggers sorted alphabetically.
*/
function getTriggers() {
if (cachedTriggers === null) {
const allTriggers = getTriggersForSidebar()
cachedTriggers = allTriggers.sort((a, b) => a.name.localeCompare(b.name))
const priorityOrder = ['Start', 'Schedule', 'Webhook']
cachedTriggers = allTriggers.sort((a, b) => {
const aIndex = priorityOrder.indexOf(a.name)
const bIndex = priorityOrder.indexOf(b.name)
const aHasPriority = aIndex !== -1
const bHasPriority = bIndex !== -1
if (aHasPriority && bHasPriority) return aIndex - bIndex
if (aHasPriority) return -1
if (bHasPriority) return 1
return a.name.localeCompare(b.name)
})
}
return cachedTriggers
}

View File

@@ -23,14 +23,17 @@ import {
Trash,
} from '@/components/emcn'
import { createLogger } from '@/lib/logs/console/logger'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useDeleteWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
import { useChatStore } from '@/stores/chat/store'
import { usePanelStore } from '@/stores/panel-new/store'
import type { PanelTab } from '@/stores/panel-new/types'
// import { useVariablesStore } from '@/stores/variables/store'
import { useWorkflowJsonStore } from '@/stores/workflows/json/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
// import { Variables } from '../variables/variables'
import { Copilot, Deploy, Editor, Toolbar } from './components'
import { usePanelResize, useRunWorkflow, useUsageLimits } from './hooks'
@@ -104,6 +107,7 @@ export function Panel() {
// Chat state
const { isChatOpen, setIsChatOpen } = useChatStore()
// const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesStore()
const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null
@@ -233,6 +237,28 @@ export function Panel() {
const isWorkflowBlocked = isExecuting || hasValidationErrors
const isButtonDisabled = !isExecuting && (isWorkflowBlocked || (!canRun && !isLoadingPermissions))
// Register global keyboard shortcuts
useRegisterGlobalCommands(() => [
{
id: 'run-workflow',
shortcut: 'Mod+Enter',
allowInEditable: false,
handler: () => {
try {
if (isExecuting) {
cancelWorkflow()
} else if (!isButtonDisabled) {
runWorkflow()
} else {
logger.warn('Cannot run workflow: button is disabled')
}
} catch (err) {
logger.error('Failed to execute Cmd+Enter command', { err })
}
},
},
])
return (
<>
<aside
@@ -259,6 +285,10 @@ export function Panel() {
<Layout className='h-3 w-3' animate={isAutoLayouting} variant='clockwise' />
<span>Auto layout</span>
</PopoverItem>
{/* <PopoverItem onClick={() => setVariablesOpen(!isVariablesOpen)}>
<Braces className='h-3 w-3' />
<span>Variables</span>
</PopoverItem> */}
{/* <PopoverItem>
<Bug className='h-3 w-3' />
<span>Debug</span>
@@ -437,6 +467,9 @@ export function Panel() {
</ModalFooter>
</ModalContent>
</Modal>
{/* Floating Variables Modal */}
{/* <Variables /> */}
</>
)
}

View File

@@ -143,7 +143,7 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
ref={blockRef}
onClick={() => setCurrentBlockId(id)}
className={cn(
'relative cursor-default select-none rounded-[8px] border border-[#393939]',
'relative cursor-pointer select-none rounded-[8px] border border-[#393939]',
'transition-block-bg transition-ring',
'z-[20]'
)}

View File

@@ -1,2 +1,3 @@
export { useOutputPanelResize } from './use-output-panel-resize'
export { useTerminalFilters } from './use-terminal-filters'
export { useTerminalResize } from './use-terminal-resize'

View File

@@ -0,0 +1,173 @@
import { useCallback, useMemo, useState } from 'react'
import type { ConsoleEntry } from '@/stores/terminal'
/**
* Sort configuration
*/
export type SortField = 'timestamp'
export type SortDirection = 'asc' | 'desc'
export interface SortConfig {
field: SortField
direction: SortDirection
}
/**
* Filter configuration state
*/
export interface TerminalFilters {
blockIds: Set<string>
statuses: Set<'error' | 'info'>
runIds: Set<string>
}
/**
* Custom hook to manage terminal filters and sorting.
* Provides filter state, sort state, and filtering/sorting logic for console entries.
*
* @returns Filter state, sort state, and handlers
*/
export function useTerminalFilters() {
const [filters, setFilters] = useState<TerminalFilters>({
blockIds: new Set(),
statuses: new Set(),
runIds: new Set(),
})
const [sortConfig, setSortConfig] = useState<SortConfig>({
field: 'timestamp',
direction: 'desc',
})
/**
* Toggles a block filter by block ID
*/
const toggleBlock = useCallback((blockId: string) => {
setFilters((prev) => {
const newBlockIds = new Set(prev.blockIds)
if (newBlockIds.has(blockId)) {
newBlockIds.delete(blockId)
} else {
newBlockIds.add(blockId)
}
return { ...prev, blockIds: newBlockIds }
})
}, [])
/**
* Toggles a status filter
*/
const toggleStatus = useCallback((status: 'error' | 'info') => {
setFilters((prev) => {
const newStatuses = new Set(prev.statuses)
if (newStatuses.has(status)) {
newStatuses.delete(status)
} else {
newStatuses.add(status)
}
return { ...prev, statuses: newStatuses }
})
}, [])
/**
* Toggles a run ID filter
*/
const toggleRunId = useCallback((runId: string) => {
setFilters((prev) => {
const newRunIds = new Set(prev.runIds)
if (newRunIds.has(runId)) {
newRunIds.delete(runId)
} else {
newRunIds.add(runId)
}
return { ...prev, runIds: newRunIds }
})
}, [])
/**
* Toggles sort direction between ascending and descending
*/
const toggleSort = useCallback(() => {
setSortConfig((prev) => ({
field: prev.field,
direction: prev.direction === 'desc' ? 'asc' : 'desc',
}))
}, [])
/**
* Clears all filters
*/
const clearFilters = useCallback(() => {
setFilters({
blockIds: new Set(),
statuses: new Set(),
runIds: new Set(),
})
}, [])
/**
* Checks if any filters are active
*/
const hasActiveFilters = useMemo(() => {
return filters.blockIds.size > 0 || filters.statuses.size > 0 || filters.runIds.size > 0
}, [filters])
/**
* Filters and sorts console entries based on current filter and sort state
*/
const filterEntries = useCallback(
(entries: ConsoleEntry[]): ConsoleEntry[] => {
// Apply filters first
let result = entries
if (hasActiveFilters) {
result = entries.filter((entry) => {
// Block ID filter
if (filters.blockIds.size > 0 && !filters.blockIds.has(entry.blockId)) {
return false
}
// Status filter
if (filters.statuses.size > 0) {
const isError = !!entry.error
const hasStatus = isError ? filters.statuses.has('error') : filters.statuses.has('info')
if (!hasStatus) return false
}
// Run ID filter
if (
filters.runIds.size > 0 &&
(!entry.executionId || !filters.runIds.has(entry.executionId))
) {
return false
}
return true
})
}
// Apply sorting by timestamp
result = [...result].sort((a, b) => {
const timeA = new Date(a.timestamp).getTime()
const timeB = new Date(b.timestamp).getTime()
const comparison = timeA - timeB
return sortConfig.direction === 'asc' ? comparison : -comparison
})
return result
},
[filters, hasActiveFilters, sortConfig]
)
return {
filters,
sortConfig,
toggleBlock,
toggleStatus,
toggleRunId,
toggleSort,
clearFilters,
hasActiveFilters,
filterEntries,
}
}

View File

@@ -3,10 +3,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import {
ArrowDown,
ArrowUp,
Check,
ChevronDown,
Clipboard,
MoreHorizontal,
Filter,
FilterX,
RepeatIcon,
SplitIcon,
Trash2,
@@ -18,17 +21,21 @@ import {
Popover,
PopoverContent,
PopoverItem,
PopoverSection,
PopoverScrollArea,
PopoverTrigger,
Tooltip,
Wrap,
} from '@/components/emcn'
import { getBlock } from '@/blocks'
import type { ConsoleEntry } from '@/stores/terminal'
import { useTerminalConsoleStore, useTerminalStore } from '@/stores/terminal'
import {
DEFAULT_TERMINAL_HEIGHT,
useTerminalConsoleStore,
useTerminalStore,
} from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { PrettierOutput } from './components'
import { useOutputPanelResize, useTerminalResize } from './hooks'
// import { PrettierOutput } from './components'
import { useOutputPanelResize, useTerminalFilters, useTerminalResize } from './hooks'
/**
* Terminal height configuration constants
@@ -119,10 +126,21 @@ const getStatusInfo = (
}
/**
* Reusable column header component
* Reusable column header component with optional filter button
*/
const ColumnHeader = ({ label, width }: { label: string; width: string }) => (
<span className={clsx(width, COLUMN_BASE_CLASS, HEADER_TEXT_CLASS)}>{label}</span>
const ColumnHeader = ({
label,
width,
filterButton,
}: {
label: string
width: string
filterButton?: React.ReactNode
}) => (
<div className={clsx(width, COLUMN_BASE_CLASS, 'flex items-center')}>
<span className={HEADER_TEXT_CLASS}>{label}</span>
{filterButton && <div className='-mt-[0.75px] ml-[8px] flex items-center'>{filterButton}</div>}
</div>
)
/**
@@ -182,6 +200,39 @@ const getRunIdColor = (
return RUN_ID_COLORS[colorIndex % RUN_ID_COLORS.length]
}
/**
* Determines if a keyboard event originated from a text-editable element.
*
* Treats native inputs, textareas, contenteditable elements, and elements with
* textbox-like roles as editable. If the event target or any of its ancestors
* match these criteria, we consider it editable and skip global key handlers.
*
* @param e - Keyboard event to inspect
* @returns True if the event is from an editable context, false otherwise
*/
const isEventFromEditableElement = (e: KeyboardEvent): boolean => {
const target = e.target as HTMLElement | null
if (!target) return false
const isEditable = (el: HTMLElement | null): boolean => {
if (!el) return false
if (el instanceof HTMLInputElement) return true
if (el instanceof HTMLTextAreaElement) return true
if ((el as HTMLElement).isContentEditable) return true
const role = el.getAttribute('role')
if (role === 'textbox' || role === 'combobox') return true
return false
}
// Check target and walk up ancestors in case editors render nested elements
let el: HTMLElement | null = target
while (el) {
if (isEditable(el)) return true
el = el.parentElement
}
return false
}
/**
* Terminal component with resizable height that persists across page refreshes.
*
@@ -196,13 +247,14 @@ const getRunIdColor = (
*/
export function Terminal() {
const terminalRef = useRef<HTMLElement>(null)
const prevEntriesLengthRef = useRef(0)
const {
terminalHeight,
setTerminalHeight,
outputPanelWidth,
setOutputPanelWidth,
displayMode,
setDisplayMode,
// displayMode,
// setDisplayMode,
setHasHydrated,
} = useTerminalStore()
const entries = useTerminalConsoleStore((state) => state.entries)
@@ -210,36 +262,99 @@ export function Terminal() {
const { activeWorkflowId } = useWorkflowRegistry()
const [selectedEntry, setSelectedEntry] = useState<ConsoleEntry | null>(null)
const [isToggling, setIsToggling] = useState(false)
const [displayPopoverOpen, setDisplayPopoverOpen] = useState(false)
// const [displayPopoverOpen, setDisplayPopoverOpen] = useState(false)
const [wrapText, setWrapText] = useState(true)
const [showCopySuccess, setShowCopySuccess] = useState(false)
const [showInput, setShowInput] = useState(false)
const [autoSelectEnabled, setAutoSelectEnabled] = useState(true)
const [blockFilterOpen, setBlockFilterOpen] = useState(false)
const [statusFilterOpen, setStatusFilterOpen] = useState(false)
const [runIdFilterOpen, setRunIdFilterOpen] = useState(false)
// Terminal resize hooks
const { handleMouseDown } = useTerminalResize()
const { handleMouseDown: handleOutputPanelResizeMouseDown } = useOutputPanelResize()
// Terminal filters hook
const {
filters,
sortConfig,
toggleBlock,
toggleStatus,
toggleRunId,
toggleSort,
clearFilters,
filterEntries,
hasActiveFilters,
} = useTerminalFilters()
const isExpanded = terminalHeight > NEAR_MIN_THRESHOLD
/**
* Filter entries for current workflow
* Get all entries for current workflow (before filtering) for filter options
*/
const filteredEntries = useMemo(() => {
const allWorkflowEntries = useMemo(() => {
if (!activeWorkflowId) return []
return entries.filter((entry) => entry.workflowId === activeWorkflowId)
}, [entries, activeWorkflowId])
/**
* Filter entries for current workflow and apply filters
*/
const filteredEntries = useMemo(() => {
return filterEntries(allWorkflowEntries)
}, [allWorkflowEntries, filterEntries])
/**
* Get unique blocks (by ID) from all workflow entries
*/
const uniqueBlocks = useMemo(() => {
const blocksMap = new Map<string, { blockId: string; blockName: string; blockType: string }>()
allWorkflowEntries.forEach((entry) => {
if (!blocksMap.has(entry.blockId)) {
blocksMap.set(entry.blockId, {
blockId: entry.blockId,
blockName: entry.blockName,
blockType: entry.blockType,
})
}
})
return Array.from(blocksMap.values()).sort((a, b) => a.blockName.localeCompare(b.blockName))
}, [allWorkflowEntries])
/**
* Get unique run IDs from all workflow entries
*/
const uniqueRunIds = useMemo(() => {
const runIdsSet = new Set<string>()
allWorkflowEntries.forEach((entry) => {
if (entry.executionId) {
runIdsSet.add(entry.executionId)
}
})
return Array.from(runIdsSet).sort()
}, [allWorkflowEntries])
/**
* Check if there are any entries with status information (error or success)
*/
const hasStatusEntries = useMemo(() => {
return allWorkflowEntries.some((entry) => entry.error || entry.success !== undefined)
}, [allWorkflowEntries])
/**
* Create stable execution ID to color index mapping based on order of first appearance.
* Once an execution ID is assigned a color index, it keeps that index.
* Uses all workflow entries to maintain consistent colors regardless of active filters.
*/
const executionIdOrderMap = useMemo(() => {
const orderMap = new Map<string, number>()
let colorIndex = 0
// Process entries in reverse order (oldest first) since entries array is newest-first
for (let i = filteredEntries.length - 1; i >= 0; i--) {
const entry = filteredEntries[i]
// Use allWorkflowEntries to ensure colors remain consistent when filters change
for (let i = allWorkflowEntries.length - 1; i >= 0; i--) {
const entry = allWorkflowEntries[i]
if (entry.executionId && !orderMap.has(entry.executionId)) {
orderMap.set(entry.executionId, colorIndex)
colorIndex++
@@ -247,7 +362,7 @@ export function Terminal() {
}
return orderMap
}, [filteredEntries])
}, [allWorkflowEntries])
/**
* Check if input data exists for selected entry
@@ -273,15 +388,22 @@ export function Terminal() {
*/
const outputData = useMemo(() => {
if (!selectedEntry) return null
if (showInput) return selectedEntry.input
if (selectedEntry.error) return selectedEntry.error
return showInput ? selectedEntry.input : selectedEntry.output
return selectedEntry.output
}, [selectedEntry, showInput])
/**
* Handle row click - toggle if clicking same entry
* Disables auto-selection when user manually selects, re-enables when deselecting
*/
const handleRowClick = useCallback((entry: ConsoleEntry) => {
setSelectedEntry((prev) => (prev?.id === entry.id ? null : entry))
setSelectedEntry((prev) => {
const isDeselecting = prev?.id === entry.id
// Re-enable auto-select when deselecting, disable when selecting
setAutoSelectEnabled(isDeselecting)
return isDeselecting ? null : entry
})
}, [])
/**
@@ -293,9 +415,7 @@ export function Terminal() {
if (isExpanded) {
setTerminalHeight(MIN_HEIGHT)
} else {
const maxHeight = window.innerHeight * 0.7
const targetHeight = Math.min(DEFAULT_EXPANDED_HEIGHT, maxHeight)
setTerminalHeight(targetHeight)
setTerminalHeight(DEFAULT_TERMINAL_HEIGHT)
}
}, [isExpanded, setTerminalHeight])
@@ -377,11 +497,38 @@ export function Terminal() {
}
}, [showCopySuccess])
/**
* Auto-select the latest entry when new logs arrive
* Re-enables auto-selection when all entries are cleared
* Only auto-selects when NEW entries are added (length increases)
*/
useEffect(() => {
if (filteredEntries.length === 0) {
// Re-enable auto-selection when console is cleared
setAutoSelectEnabled(true)
setSelectedEntry(null)
prevEntriesLengthRef.current = 0
return
}
// Auto-select the latest entry only when a NEW entry is added (length increased)
if (autoSelectEnabled && filteredEntries.length > prevEntriesLengthRef.current) {
const latestEntry = filteredEntries[0]
setSelectedEntry(latestEntry)
}
prevEntriesLengthRef.current = filteredEntries.length
}, [filteredEntries, autoSelectEnabled])
/**
* Handle keyboard navigation through logs
* Disables auto-selection when user manually navigates
*/
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore when typing/navigating inside editable inputs/editors
if (isEventFromEditableElement(e)) return
if (!selectedEntry || filteredEntries.length === 0) return
// Only handle arrow keys
@@ -394,8 +541,10 @@ export function Terminal() {
if (currentIndex === -1) return
if (e.key === 'ArrowUp' && currentIndex > 0) {
setAutoSelectEnabled(false)
setSelectedEntry(filteredEntries[currentIndex - 1])
} else if (e.key === 'ArrowDown' && currentIndex < filteredEntries.length - 1) {
setAutoSelectEnabled(false)
setSelectedEntry(filteredEntries[currentIndex + 1])
}
}
@@ -404,6 +553,65 @@ export function Terminal() {
return () => window.removeEventListener('keydown', handleKeyDown)
}, [selectedEntry, filteredEntries])
/**
* Handle keyboard navigation for input/output toggle
* Left arrow shows output, right arrow shows input
*/
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore when typing/navigating inside editable inputs/editors
if (isEventFromEditableElement(e)) return
if (!selectedEntry) return
// Only handle left/right arrow keys
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
// Prevent default scrolling behavior
e.preventDefault()
// Expand terminal if collapsed
if (!isExpanded) {
setIsToggling(true)
const maxHeight = window.innerHeight * 0.7
const targetHeight = Math.min(DEFAULT_EXPANDED_HEIGHT, maxHeight)
setTerminalHeight(targetHeight)
}
if (e.key === 'ArrowLeft') {
// Show output
if (showInput) {
setShowInput(false)
}
} else if (e.key === 'ArrowRight') {
// Show input (only if input data exists)
if (!showInput && hasInputData) {
setShowInput(true)
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [selectedEntry, showInput, hasInputData, isExpanded])
/**
* Handle Escape to unselect and Enter to re-enable auto-selection
*/
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && selectedEntry) {
// Escape unselects the current entry and re-enables auto-selection
e.preventDefault()
setSelectedEntry(null)
setAutoSelectEnabled(true)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [selectedEntry])
/**
* Adjust output panel width when sidebar or panel width changes.
* Ensures output panel doesn't exceed maximum allowed width.
@@ -483,13 +691,224 @@ export function Terminal() {
className='group flex h-[30px] flex-shrink-0 cursor-pointer items-center bg-[#1E1E1E] pr-[16px] pl-[24px]'
onClick={handleHeaderClick}
>
<ColumnHeader label='Block' width={COLUMN_WIDTHS.BLOCK} />
<ColumnHeader label='Status' width={COLUMN_WIDTHS.STATUS} />
<ColumnHeader label='Run ID' width={COLUMN_WIDTHS.RUN_ID} />
{uniqueBlocks.length > 0 ? (
<div className={clsx(COLUMN_WIDTHS.BLOCK, COLUMN_BASE_CLASS, 'flex items-center')}>
<Popover open={blockFilterOpen} onOpenChange={setBlockFilterOpen}>
<PopoverTrigger asChild>
<Button
variant='ghost'
className='!h-auto !p-0 flex items-center'
onClick={(e) => e.stopPropagation()}
aria-label='Filter by block'
>
<span className={HEADER_TEXT_CLASS}>Block</span>
<div className='-mt-[0.75px] ml-[8px] flex items-center'>
<Filter
className={clsx(
'h-[11px] w-[11px]',
filters.blockIds.size > 0 && 'text-[#33B4FF]'
)}
/>
</div>
</Button>
</PopoverTrigger>
<PopoverContent
side='bottom'
align='start'
sideOffset={4}
onClick={(e) => e.stopPropagation()}
style={{ minWidth: '120px', maxWidth: '120px' }}
>
<PopoverScrollArea style={{ maxHeight: '140px' }}>
{uniqueBlocks.map((block, index) => {
const BlockIcon = getBlockIcon(block.blockType)
const isSelected = filters.blockIds.has(block.blockId)
return (
<PopoverItem
key={block.blockId}
active={isSelected}
onClick={() => toggleBlock(block.blockId)}
className={index > 0 ? 'mt-[2px]' : ''}
>
{BlockIcon && <BlockIcon className='h-3 w-3' />}
<span className='flex-1'>{block.blockName}</span>
{isSelected && <Check className='h-3 w-3' />}
</PopoverItem>
)
})}
</PopoverScrollArea>
</PopoverContent>
</Popover>
</div>
) : (
<ColumnHeader label='Block' width={COLUMN_WIDTHS.BLOCK} />
)}
{hasStatusEntries ? (
<div className={clsx(COLUMN_WIDTHS.STATUS, COLUMN_BASE_CLASS, 'flex items-center')}>
<Popover open={statusFilterOpen} onOpenChange={setStatusFilterOpen}>
<PopoverTrigger asChild>
<Button
variant='ghost'
className='!h-auto !p-0 flex items-center'
onClick={(e) => e.stopPropagation()}
aria-label='Filter by status'
>
<span className={HEADER_TEXT_CLASS}>Status</span>
<div className='-mt-[0.75px] ml-[8px] flex items-center'>
<Filter
className={clsx(
'h-[11px] w-[11px]',
filters.statuses.size > 0 && 'text-[#33B4FF]'
)}
/>
</div>
</Button>
</PopoverTrigger>
<PopoverContent
side='bottom'
align='start'
sideOffset={4}
onClick={(e) => e.stopPropagation()}
style={{ minWidth: '120px', maxWidth: '120px' }}
>
<PopoverScrollArea style={{ maxHeight: '140px' }}>
<PopoverItem
active={filters.statuses.has('error')}
onClick={() => toggleStatus('error')}
>
<div
className='h-[6px] w-[6px] rounded-[2px]'
style={{ backgroundColor: '#EF4444' }}
/>
<span className='flex-1'>Error</span>
{filters.statuses.has('error') && <Check className='h-3 w-3' />}
</PopoverItem>
<PopoverItem
active={filters.statuses.has('info')}
onClick={() => toggleStatus('info')}
className='mt-[2px]'
>
<div
className='h-[6px] w-[6px] rounded-[2px]'
style={{ backgroundColor: '#B7B7B7' }}
/>
<span className='flex-1'>Info</span>
{filters.statuses.has('info') && <Check className='h-3 w-3' />}
</PopoverItem>
</PopoverScrollArea>
</PopoverContent>
</Popover>
</div>
) : (
<ColumnHeader label='Status' width={COLUMN_WIDTHS.STATUS} />
)}
{uniqueRunIds.length > 0 ? (
<div className={clsx(COLUMN_WIDTHS.RUN_ID, COLUMN_BASE_CLASS, 'flex items-center')}>
<Popover open={runIdFilterOpen} onOpenChange={setRunIdFilterOpen}>
<PopoverTrigger asChild>
<Button
variant='ghost'
className='!h-auto !p-0 flex items-center'
onClick={(e) => e.stopPropagation()}
aria-label='Filter by run ID'
>
<span className={HEADER_TEXT_CLASS}>Run ID</span>
<div className='-mt-[0.75px] ml-[8px] flex items-center'>
<Filter
className={clsx(
'h-[11px] w-[11px]',
filters.runIds.size > 0 && 'text-[#33B4FF]'
)}
/>
</div>
</Button>
</PopoverTrigger>
<PopoverContent
side='bottom'
align='start'
sideOffset={4}
onClick={(e) => e.stopPropagation()}
style={{ minWidth: '90px', maxWidth: '90px' }}
>
<PopoverScrollArea style={{ maxHeight: '140px' }}>
{uniqueRunIds.map((runId, index) => {
const isSelected = filters.runIds.has(runId)
const runIdColor = getRunIdColor(runId, executionIdOrderMap)
return (
<PopoverItem
key={runId}
active={isSelected}
onClick={() => toggleRunId(runId)}
className={index > 0 ? 'mt-[2px]' : ''}
>
<span
className='flex-1 font-mono text-[12px]'
style={{ color: runIdColor?.text || '#D2D2D2' }}
>
{formatRunId(runId)}
</span>
{isSelected && <Check className='h-3 w-3' />}
</PopoverItem>
)
})}
</PopoverScrollArea>
</PopoverContent>
</Popover>
</div>
) : (
<ColumnHeader label='Run ID' width={COLUMN_WIDTHS.RUN_ID} />
)}
<ColumnHeader label='Duration' width={COLUMN_WIDTHS.DURATION} />
<ColumnHeader label='Timestamp' width={COLUMN_WIDTHS.TIMESTAMP} />
{allWorkflowEntries.length > 0 ? (
<div
className={clsx(COLUMN_WIDTHS.TIMESTAMP, COLUMN_BASE_CLASS, 'flex items-center')}
>
<Button
variant='ghost'
className='!h-auto !p-0 flex items-center'
onClick={(e) => {
e.stopPropagation()
toggleSort()
}}
aria-label='Sort by timestamp'
>
<span className={HEADER_TEXT_CLASS}>Timestamp</span>
<div className='-mt-[0.75px] ml-[8px] flex items-center'>
{sortConfig.direction === 'desc' ? (
<ArrowDown className='h-[13px] w-[13px]' />
) : (
<ArrowUp className='h-[13px] w-[13px]' />
)}
</div>
</Button>
</div>
) : (
<ColumnHeader label='Timestamp' width={COLUMN_WIDTHS.TIMESTAMP} />
)}
{!selectedEntry && (
<div className='ml-auto flex items-center gap-[8px]'>
{hasActiveFilters && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
clearFilters()
}}
aria-label='Clear filters'
className='!p-1.5 -m-1.5'
>
<FilterX className='h-3 w-3' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Clear filters</span>
</Tooltip.Content>
</Tooltip.Root>
)}
{filteredEntries.length > 0 && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
@@ -743,7 +1162,7 @@ export function Terminal() {
<span>{wrapText ? 'Wrap text' : 'No wrap'}</span>
</Tooltip.Content>
</Tooltip.Root>
<Popover open={displayPopoverOpen} onOpenChange={setDisplayPopoverOpen}>
{/* <Popover open={displayPopoverOpen} onOpenChange={setDisplayPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant='ghost'
@@ -756,7 +1175,13 @@ export function Terminal() {
<MoreHorizontal className='h-3.5 w-3.5' />
</Button>
</PopoverTrigger>
<PopoverContent side='bottom' align='end' sideOffset={4} collisionPadding={0}>
<PopoverContent
side='bottom'
align='end'
sideOffset={4}
collisionPadding={0}
onClick={(e) => e.stopPropagation()}
>
<PopoverSection>Display</PopoverSection>
<PopoverItem
active={displayMode === 'prettier'}
@@ -780,7 +1205,27 @@ export function Terminal() {
<span>Raw</span>
</PopoverItem>
</PopoverContent>
</Popover>
</Popover> */}
{hasActiveFilters && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
clearFilters()
}}
aria-label='Clear filters'
className='!p-1.5 -m-1.5'
>
<FilterX className='h-3 w-3' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Clear filters</span>
</Tooltip.Content>
</Tooltip.Root>
)}
{filteredEntries.length > 0 && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
@@ -810,10 +1255,11 @@ export function Terminal() {
{/* Content */}
<div
className={clsx(
'flex-1 overflow-x-auto overflow-y-auto',
displayMode === 'prettier' && 'px-[8px] pb-[8px]'
)}
className='flex-1 overflow-x-auto overflow-y-auto'
// className={clsx(
// 'flex-1 overflow-x-auto overflow-y-auto',
// displayMode === 'prettier' && 'px-[8px] pb-[8px]'
// )}
>
{shouldShowCodeDisplay ? (
<Code.Viewer
@@ -827,7 +1273,18 @@ export function Terminal() {
gutterStyle={{ backgroundColor: 'transparent' }}
wrapText={wrapText}
/>
) : displayMode === 'raw' ? (
) : (
<Code.Viewer
code={JSON.stringify(outputData, null, 2)}
showGutter
language='json'
className='m-0 min-h-full rounded-none border-0 bg-[#1E1E1E]'
paddingLeft={8}
gutterStyle={{ backgroundColor: 'transparent' }}
wrapText={wrapText}
/>
)}
{/* ) : displayMode === 'raw' ? (
<Code.Viewer
code={JSON.stringify(outputData, null, 2)}
showGutter
@@ -839,7 +1296,7 @@ export function Terminal() {
/>
) : (
<PrettierOutput output={outputData} wrapText={wrapText} />
)}
)} */}
</div>
</div>
)}

View File

@@ -1,249 +0,0 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Info, Plus, Search, X } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import { Input } from '@/components/ui/input'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { getAllTriggerBlocks, getTriggerDisplayName } from '@/lib/workflows/trigger-utils'
const logger = createLogger('TriggerList')
interface TriggerListProps {
onSelect: (triggerId: string, enableTriggerMode?: boolean) => void
className?: string
}
export function TriggerList({ onSelect, className }: TriggerListProps) {
const [searchQuery, setSearchQuery] = useState('')
const [showList, setShowList] = useState(false)
const listRef = useRef<HTMLDivElement>(null)
// Get all trigger options from the centralized source
const triggerOptions = useMemo(() => getAllTriggerBlocks(), [])
// Handle escape key
useEffect(() => {
if (!showList) return
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
logger.info('Closing trigger list via escape key')
setShowList(false)
setSearchQuery('')
}
}
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('keydown', handleEscape)
}
}, [showList])
const filteredOptions = useMemo(() => {
if (!searchQuery.trim()) return triggerOptions
const query = searchQuery.toLowerCase()
return triggerOptions.filter(
(option) =>
option.name.toLowerCase().includes(query) ||
option.description.toLowerCase().includes(query)
)
}, [searchQuery, triggerOptions])
const coreOptions = useMemo(
() => filteredOptions.filter((opt) => opt.category === 'core'),
[filteredOptions]
)
const integrationOptions = useMemo(
() => filteredOptions.filter((opt) => opt.category === 'integration'),
[filteredOptions]
)
const handleTriggerClick = (triggerId: string, enableTriggerMode?: boolean) => {
logger.info('Trigger selected', { triggerId, enableTriggerMode })
onSelect(triggerId, enableTriggerMode)
// Reset state after selection
setShowList(false)
setSearchQuery('')
}
const handleClose = () => {
logger.info('Closing trigger list via X button')
setShowList(false)
setSearchQuery('')
}
const TriggerItem = ({ trigger }: { trigger: (typeof triggerOptions)[0] }) => {
const Icon = trigger.icon
return (
<div
className={cn(
'flex h-10 w-[200px] flex-shrink-0 cursor-pointer items-center gap-[10px] rounded-[8px] border px-1.5 transition-all duration-200',
'border-border/40 bg-background/60 hover:border-border hover:bg-secondary/80'
)}
>
<div
onClick={() => handleTriggerClick(trigger.id, trigger.enableTriggerMode)}
className='flex flex-1 items-center gap-[10px]'
>
<div
className='relative flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-[6px]'
style={{ backgroundColor: trigger.color }}
>
{Icon ? (
<Icon className='!h-4 !w-4 text-white' />
) : (
<div className='h-4 w-4 rounded bg-white/20' />
)}
</div>
<span className='flex-1 truncate font-medium text-sm leading-none'>
{getTriggerDisplayName(trigger.id)}
</span>
</div>
<Tooltip.Root delayDuration={300}>
<Tooltip.Trigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className='flex h-6 w-6 items-center justify-center rounded-md'
>
<Info className='h-3.5 w-3.5 text-muted-foreground' />
</button>
</Tooltip.Trigger>
<Tooltip.Content
side='top'
sideOffset={5}
className='z-[9999] max-w-[200px]'
align='center'
avoidCollisions={false}
>
<p className='text-xs'>{trigger.description}</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
)
}
return (
<div
className={cn(
'pointer-events-none absolute inset-0 flex items-center justify-center',
className
)}
>
{!showList ? (
/* Initial Button State */
<button
onClick={() => {
logger.info('Opening trigger list')
setShowList(true)
}}
className={cn(
'pointer-events-auto',
'flex items-center gap-2',
'px-4 py-2',
'rounded-lg border border-muted-foreground/50 border-dashed',
'bg-background/95 backdrop-blur-sm',
'hover:border-muted-foreground hover:bg-muted',
'transition-all duration-200',
'font-medium text-muted-foreground text-sm'
)}
>
<Plus className='h-4 w-4' />
Click to Add Trigger
</button>
) : (
/* Trigger List View */
<div
ref={listRef}
className={cn(
'pointer-events-auto',
'max-h-[400px] w-[650px]',
'rounded-xl border border-border',
'bg-background/95 backdrop-blur-sm',
'shadow-lg',
'flex flex-col',
'relative'
)}
>
{/* Search - matching search modal exactly */}
<div className='flex items-center border-b px-4 py-1'>
<Search className='h-4 w-4 font-sans text-muted-foreground text-xl' />
<Input
placeholder='Search triggers'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className='!font-[350] border-0 bg-transparent font-sans text-muted-foreground leading-10 tracking-normal placeholder:text-muted-foreground focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
autoFocus
/>
</div>
{/* Close button */}
<button
onClick={handleClose}
className='absolute top-4 right-4 h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground focus:outline-none disabled:pointer-events-none'
tabIndex={-1}
>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</button>
{/* Trigger List */}
<div
className='flex-1 overflow-y-auto'
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
<div className='space-y-4 pt-4 pb-4'>
{/* Core Triggers Section */}
{coreOptions.length > 0 && (
<div>
<h3 className='mb-2 ml-4 font-normal font-sans text-[13px] text-muted-foreground leading-none tracking-normal'>
Core Triggers
</h3>
<div className='px-4 pb-1'>
{/* Display triggers in a 3-column grid */}
<div className='grid grid-cols-3 gap-2'>
{coreOptions.map((trigger) => (
<TriggerItem key={trigger.id} trigger={trigger} />
))}
</div>
</div>
</div>
)}
{/* Integration Triggers Section */}
{integrationOptions.length > 0 && (
<div>
<h3 className='mb-2 ml-4 font-normal font-sans text-[13px] text-muted-foreground leading-none tracking-normal'>
Integration Triggers
</h3>
<div
className='max-h-[200px] overflow-y-auto px-4 pb-1'
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{/* Display triggers in a 3-column grid */}
<div className='grid grid-cols-3 gap-2'>
{integrationOptions.map((trigger) => (
<TriggerItem key={trigger.id} trigger={trigger} />
))}
</div>
</div>
</div>
)}
{filteredOptions.length === 0 && (
<div className='ml-6 py-12 text-center'>
<p className='text-muted-foreground'>No results found for "{searchQuery}"</p>
</div>
)}
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,447 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Plus, Trash, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import Editor from 'react-simple-code-editor'
import type { ComboboxOption } from '@/components/emcn'
import {
Badge,
Button,
Code,
Combobox,
calculateGutterWidth,
getCodeEditorProps,
highlight,
Input,
languages,
} from '@/components/emcn'
import { Label } from '@/components/ui/label'
import { createLogger } from '@/lib/logs/console/logger'
import { cn, validateName } from '@/lib/utils'
import { getVariablesPosition, useVariablesStore } from '@/stores/variables/store'
import type { Variable } from '@/stores/variables/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useChatBoundarySync, useChatDrag, useChatResize } from '../chat/hooks'
const logger = createLogger('FloatingVariables')
/**
* Type options for variable type selection
*/
const TYPE_OPTIONS: ComboboxOption[] = [
{ label: 'Plain', value: 'plain' },
{ label: 'Number', value: 'number' },
{ label: 'Boolean', value: 'boolean' },
{ label: 'Object', value: 'object' },
{ label: 'Array', value: 'array' },
]
/**
* Floating Variables modal component
*
* Matches the visual and interaction style of the Chat modal:
* - Draggable and resizable within the canvas bounds
* - Persists position and size
* - Uses emcn Input/Code/Combobox components for a consistent UI
*/
export function Variables() {
const params = useParams()
const workspaceId = params.workspaceId as string
const { activeWorkflowId } = useWorkflowRegistry()
// UI store
const {
isOpen,
position,
width,
height,
setIsOpen,
setPosition,
setDimensions,
// Data
variables,
loadForWorkflow,
addVariable,
updateVariable,
deleteVariable,
getVariablesByWorkflowId,
} = useVariablesStore()
// Local UI helpers
const actualPosition = useMemo(
() => getVariablesPosition(position, width, height),
[position, width, height]
)
const { handleMouseDown } = useChatDrag({
position: actualPosition,
width,
height,
onPositionChange: setPosition,
})
useChatBoundarySync({
isOpen,
position: actualPosition,
width,
height,
onPositionChange: setPosition,
})
const {
cursor: resizeCursor,
handleMouseMove: handleResizeMouseMove,
handleMouseLeave: handleResizeMouseLeave,
handleMouseDown: handleResizeMouseDown,
} = useChatResize({
position: actualPosition,
width,
height,
onPositionChange: setPosition,
onDimensionsChange: setDimensions,
})
// Data for current workflow
const workflowVariables = useMemo(
() => (activeWorkflowId ? getVariablesByWorkflowId(activeWorkflowId) : []),
[activeWorkflowId, getVariablesByWorkflowId, variables]
)
useEffect(() => {
if (activeWorkflowId) {
loadForWorkflow(activeWorkflowId).catch((e) => logger.error('loadForWorkflow failed', e))
}
}, [activeWorkflowId, loadForWorkflow])
// Ensure variables are loaded when the modal is opened
useEffect(() => {
if (isOpen && activeWorkflowId) {
loadForWorkflow(activeWorkflowId).catch((e) => logger.error('loadForWorkflow failed', e))
}
}, [isOpen, activeWorkflowId, loadForWorkflow])
// Local per-variable UI state
const [collapsedById, setCollapsedById] = useState<Record<string, boolean>>({})
const [localNames, setLocalNames] = useState<Record<string, string>>({})
const [nameErrors, setNameErrors] = useState<Record<string, string>>({})
/**
* Toggles the collapsed state of a variable
*/
const toggleCollapsed = (variableId: string) => {
setCollapsedById((prev) => ({
...prev,
[variableId]: !prev[variableId],
}))
}
/**
* Clear local name/error state for a variable
*/
const clearLocalState = (variableId: string) => {
setLocalNames((prev) => {
const updated = { ...prev }
delete updated[variableId]
return updated
})
setNameErrors((prev) => {
const updated = { ...prev }
delete updated[variableId]
return updated
})
}
/**
* Clear error for a specific variable if present
*/
const clearError = (variableId: string) => {
setNameErrors((prev) => {
if (!prev[variableId]) return prev
const updated = { ...prev }
delete updated[variableId]
return updated
})
}
/**
* Adds a new variable to the list
*/
const handleAddVariable = () => {
if (!activeWorkflowId) return
addVariable({
name: '',
type: 'plain',
value: '',
workflowId: activeWorkflowId,
})
}
/**
* Removes a variable by ID
*/
const handleRemoveVariable = (variableId: string) => {
deleteVariable(variableId)
}
/**
* Updates a specific variable property
*/
const handleUpdateVariable = (variableId: string, field: keyof Variable, value: any) => {
const validatedValue =
field === 'name' && typeof value === 'string' ? validateName(value) : value
updateVariable(variableId, { [field]: validatedValue })
}
/**
* Local handlers for name editing with validation akin to panel behavior
*/
const isDuplicateName = (variableId: string, name: string): boolean => {
if (!name.trim()) return false
return workflowVariables.some((v) => v.id !== variableId && v.name === name.trim())
}
const handleVariableNameChange = (variableId: string, newName: string) => {
const validatedName = validateName(newName)
setLocalNames((prev) => ({
...prev,
[variableId]: validatedName,
}))
clearError(variableId)
}
const handleVariableNameBlur = (variableId: string) => {
const localName = localNames[variableId]
if (localName === undefined) return
const trimmedName = localName.trim()
if (!trimmedName) {
setNameErrors((prev) => ({
...prev,
[variableId]: 'Variable name cannot be empty',
}))
return
}
if (isDuplicateName(variableId, trimmedName)) {
setNameErrors((prev) => ({
...prev,
[variableId]: 'Two variables cannot have the same name',
}))
return
}
updateVariable(variableId, { name: trimmedName })
clearLocalState(variableId)
}
const handleVariableNameKeyDown = (
variableId: string,
e: React.KeyboardEvent<HTMLInputElement>
) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
}
}
/**
* Formats variable value for display in editor
*/
const formatValue = (variable: Variable) => {
if (variable.value === '') return ''
return typeof variable.value === 'string' ? variable.value : JSON.stringify(variable.value)
}
const handleClose = useCallback(() => {
setIsOpen(false)
}, [setIsOpen])
/**
* Renders the variable header with name, type badge, and action buttons
*/
const renderVariableHeader = (variable: Variable, index: number) => (
<div
className='flex cursor-pointer items-center justify-between bg-transparent px-[10px] py-[5px]'
onClick={() => toggleCollapsed(variable.id)}
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<span className='block truncate font-medium text-[#AEAEAE] text-[14px]'>
{variable.name || `Variable ${index + 1}`}
</span>
{variable.name && <Badge className='h-[20px] text-[13px]'>{variable.type}</Badge>}
</div>
<div className='flex items-center gap-[8px] pl-[8px]' onClick={(e) => e.stopPropagation()}>
<Button variant='ghost' onClick={handleAddVariable} className='h-auto p-0'>
<Plus className='h-[14px] w-[14px]' />
<span className='sr-only'>Add Variable</span>
</Button>
<Button
variant='ghost'
onClick={() => handleRemoveVariable(variable.id)}
className='h-auto p-0 text-[#EF4444] hover:text-[#EF4444]'
>
<Trash className='h-[14px] w-[14px]' />
<span className='sr-only'>Delete Variable</span>
</Button>
</div>
</div>
)
/**
* Renders the value input based on variable type
*/
const renderValueInput = (variable: Variable) => {
const variableValue = formatValue(variable)
if (variable.type === 'object' || variable.type === 'array') {
const lineCount = variableValue.split('\n').length
const gutterWidth = calculateGutterWidth(lineCount)
const placeholder = variable.type === 'object' ? '{\n "key": "value"\n}' : '[\n 1, 2, 3\n]'
const renderLineNumbers = () => {
return Array.from({ length: lineCount }, (_, i) => (
<div
key={i}
className='font-medium font-mono text-[#787878] text-xs'
style={{ height: `${21}px`, lineHeight: `${21}px` }}
>
{i + 1}
</div>
))
}
return (
<Code.Container className='min-h-[120px]'>
<Code.Gutter width={gutterWidth}>{renderLineNumbers()}</Code.Gutter>
<Code.Content paddingLeft={`${gutterWidth}px`}>
<Code.Placeholder gutterWidth={gutterWidth} show={variableValue.length === 0}>
{placeholder}
</Code.Placeholder>
<Editor
value={variableValue}
onValueChange={(newValue) => handleUpdateVariable(variable.id, 'value', newValue)}
highlight={(code) => highlight(code, languages.json, 'json')}
{...getCodeEditorProps()}
/>
</Code.Content>
</Code.Container>
)
}
return (
<Input
name='value'
value={variableValue}
onChange={(e) => handleUpdateVariable(variable.id, 'value', e.target.value)}
placeholder={
variable.type === 'number'
? '42'
: variable.type === 'boolean'
? 'true'
: 'Plain text value'
}
/>
)
}
if (!isOpen) return null
return (
<div
className='fixed z-30 flex flex-col overflow-hidden rounded-[6px] bg-[#1E1E1E] px-[10px] pt-[2px] pb-[8px]'
style={{
left: `${actualPosition.x}px`,
top: `${actualPosition.y}px`,
width: `${width}px`,
height: `${height}px`,
cursor: resizeCursor || undefined,
}}
onMouseMove={handleResizeMouseMove}
onMouseLeave={handleResizeMouseLeave}
onMouseDown={handleResizeMouseDown}
>
{/* Header (drag handle) */}
<div
className='flex h-[32px] flex-shrink-0 cursor-grab items-center justify-between bg-[#1E1E1E] p-0 active:cursor-grabbing'
onMouseDown={handleMouseDown}
>
<div className='flex items-center'>
<span className='flex-shrink-0 font-medium text-[#E6E6E6] text-[14px]'>Variables</span>
</div>
<div className='flex items-center gap-[8px]'>
<Button
variant='ghost'
className='!p-1.5 -m-1.5'
onClick={(e) => {
e.stopPropagation()
handleAddVariable()
}}
>
<Plus className='h-[16px] w-[16px]' />
</Button>
<Button variant='ghost' className='!p-1.5 -m-1.5' onClick={handleClose}>
<X className='h-[16px] w-[16px]' />
</Button>
</div>
</div>
{/* Content */}
<div className='flex flex-1 flex-col overflow-hidden pt-[8px]'>
{workflowVariables.length === 0 ? (
<div className='flex flex-1 items-center justify-center text-[#8D8D8D] text-[13px]'>
No variables yet
</div>
) : (
<div className='h-full overflow-y-auto overflow-x-hidden'>
<div className='w-full max-w-full space-y-[8px] overflow-hidden'>
{workflowVariables.map((variable, index) => (
<div
key={variable.id}
className={cn(
'rounded-[4px] border border-[#303030] bg-[#1F1F1F]',
(collapsedById[variable.id] ?? false) ? 'overflow-hidden' : 'overflow-visible'
)}
>
{renderVariableHeader(variable, index)}
{!(collapsedById[variable.id] ?? false) && (
<div className='flex flex-col gap-[6px] border-[#303030] border-t px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[4px]'>
<Label className='text-[13px]'>Name</Label>
<Input
name='name'
value={localNames[variable.id] ?? variable.name}
onChange={(e) => handleVariableNameChange(variable.id, e.target.value)}
onBlur={() => handleVariableNameBlur(variable.id)}
onKeyDown={(e) => handleVariableNameKeyDown(variable.id, e)}
placeholder='variableName'
/>
{nameErrors[variable.id] && (
<div className='mt-1 text-red-400 text-xs'>{nameErrors[variable.id]}</div>
)}
</div>
<div className='space-y-[4px]'>
<Label className='text-[13px]'>Type</Label>
<Combobox
options={TYPE_OPTIONS}
value={variable.type}
onChange={(value) => handleUpdateVariable(variable.id, 'type', value)}
/>
</div>
<div className='space-y-[4px]'>
<Label className='text-[13px]'>Value</Label>
<div className='relative'>{renderValueInput(variable)}</div>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -3,7 +3,6 @@ import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-r
import { Button, Duplicate, Tooltip, Trash2 } from '@/components/emcn'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { supportsHandles } from '@/executor/consts'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -83,7 +82,8 @@ export const ActionBar = memo(
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => {
onClick={(e) => {
e.stopPropagation()
if (!disabled) {
collaborativeToggleBlockEnabled(blockId)
}
@@ -108,7 +108,8 @@ export const ActionBar = memo(
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => {
onClick={(e) => {
e.stopPropagation()
if (!disabled) {
collaborativeDuplicateBlock(blockId)
}
@@ -128,7 +129,8 @@ export const ActionBar = memo(
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => {
onClick={(e) => {
e.stopPropagation()
if (!disabled && userPermissions.canEdit) {
window.dispatchEvent(
new CustomEvent('remove-from-subflow', { detail: { blockId } })
@@ -147,38 +149,38 @@ export const ActionBar = memo(
</Tooltip.Root>
)}
{supportsHandles(blockType) && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => {
if (!disabled) {
collaborativeToggleBlockHandles(blockId)
}
}}
className='h-[30px] w-[30px] rounded-[8px] bg-[#363636] p-0 text-[#868686] hover:bg-[#33B4FF] hover:text-[#1B1B1B] dark:text-[#868686] dark:hover:bg-[#33B4FF] dark:hover:text-[#1B1B1B]'
disabled={disabled}
>
{horizontalHandles ? (
<ArrowLeftRight className='h-[14px] w-[14px]' />
) : (
<ArrowUpDown className='h-[14px] w-[14px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='right'>
{getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
</Tooltip.Content>
</Tooltip.Root>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (!disabled) {
collaborativeToggleBlockHandles(blockId)
}
}}
className='h-[30px] w-[30px] rounded-[8px] bg-[#363636] p-0 text-[#868686] hover:bg-[#33B4FF] hover:text-[#1B1B1B] dark:text-[#868686] dark:hover:bg-[#33B4FF] dark:hover:text-[#1B1B1B]'
disabled={disabled}
>
{horizontalHandles ? (
<ArrowLeftRight className='h-[14px] w-[14px]' />
) : (
<ArrowUpDown className='h-[14px] w-[14px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='right'>
{getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
</Tooltip.Content>
</Tooltip.Root>
{!isStarterBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => {
onClick={(e) => {
e.stopPropagation()
if (!disabled) {
collaborativeRemoveBlock(blockId)
}

View File

@@ -687,7 +687,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
<config.icon className='h-[16px] w-[16px] text-white' />
</div>
<span
className={cn('font-medium text-[16px]', !isEnabled && 'truncate text-[#808080]')}
className={cn('truncate font-medium text-[16px]', !isEnabled && 'text-[#808080]')}
title={name}
>
{name}

View File

@@ -1,114 +0,0 @@
'use client'
import { memo, useMemo } from 'react'
import { useViewport } from 'reactflow'
import { useSession } from '@/lib/auth-client'
import { getPresenceColors } from '@/lib/collaboration/presence-colors'
import { useSocket } from '@/contexts/socket-context'
interface CursorPoint {
x: number
y: number
}
interface CursorRenderData {
id: string
name: string
cursor: CursorPoint
gradient: string
accentColor: string
}
const POINTER_OFFSET = {
x: 2,
y: 18,
}
const LABEL_BACKGROUND = 'rgba(15, 23, 42, 0.88)'
const CollaboratorCursorLayerComponent = () => {
const { presenceUsers } = useSocket()
const viewport = useViewport()
const session = useSession()
const currentUserId = session.data?.user?.id
const cursors = useMemo<CursorRenderData[]>(() => {
if (!presenceUsers.length) {
return []
}
return presenceUsers
.filter((user): user is typeof user & { cursor: CursorPoint } => Boolean(user.cursor))
.filter((user) => user.userId !== currentUserId)
.map((user) => {
const cursor = user.cursor
const name = user.userName?.trim() || 'Collaborator'
const { gradient, accentColor } = getPresenceColors(user.userId)
return {
id: user.socketId,
name,
cursor,
gradient,
accentColor,
}
})
}, [currentUserId, presenceUsers])
if (!cursors.length) {
return null
}
return (
<div className='pointer-events-none absolute inset-0 z-30 select-none'>
{cursors.map(({ id, name, cursor, gradient, accentColor }) => {
const x = cursor.x * viewport.zoom + viewport.x
const y = cursor.y * viewport.zoom + viewport.y
return (
<div
key={id}
className='pointer-events-none absolute'
style={{
transform: `translate3d(${x}px, ${y}px, 0)`,
transition: 'transform 0.12s ease-out',
}}
>
<div
className='relative'
style={{ transform: `translate(${-POINTER_OFFSET.x}px, ${-POINTER_OFFSET.y}px)` }}
>
<svg
width={20}
height={22}
viewBox='0 0 20 22'
className='drop-shadow-md'
style={{ fill: accentColor, stroke: 'white', strokeWidth: 1.25 }}
>
<path d='M1 0L1 17L6.2 12.5L10.5 21.5L13.7 19.8L9.4 10.7L18.5 10.7L1 0Z' />
</svg>
<div
className='absolute top-[-28px] left-4 flex items-center gap-2 rounded-full px-2 py-1 font-medium text-white text-xs shadow-lg'
style={{
background: LABEL_BACKGROUND,
border: `1px solid ${accentColor}`,
backdropFilter: 'blur(8px)',
}}
>
<span
className='h-2.5 w-2.5 rounded-full border border-white/60'
style={{ background: gradient }}
/>
<span>{name}</span>
</div>
</div>
</div>
)
})}
</div>
)
}
export const CollaboratorCursorLayer = memo(CollaboratorCursorLayerComponent)
CollaboratorCursorLayer.displayName = 'CollaboratorCursorLayer'

View File

@@ -14,23 +14,25 @@ import 'reactflow/dist/style.css'
import { createLogger } from '@/lib/logs/console/logger'
import { TriggerUtils } from '@/lib/workflows/triggers'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { DiffControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
import {
CommandList,
DiffControls,
Panel,
SubflowNodeComponent,
Terminal,
TrainingControls,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
import { Chat } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat'
import { UserAvatarStack } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/user-avatar-stack'
import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors'
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 { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/panel-new'
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
import { Terminal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal'
import { TrainingControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/training-controls/training-controls'
import { TriggerList } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-list/trigger-list'
import {
TriggerWarningDialog,
TriggerWarningType,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-warning-dialog/trigger-warning-dialog'
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
import { CollaboratorCursorLayer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-presence/collaborator-cursor-layer'
import {
useAutoLayout,
useCurrentWorkflow,
@@ -2034,6 +2036,13 @@ const WorkflowContent = React.memo(() => {
onDragOver={effectivePermissions.canEdit ? onDragOver : undefined}
fitView
fitViewOptions={{ padding: 0.6 }}
onInit={(instance) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
instance.fitView({ padding: 0.3 })
})
})
}}
minZoom={0.1}
maxZoom={1.3}
panOnScroll
@@ -2071,7 +2080,7 @@ const WorkflowContent = React.memo(() => {
autoPanOnNodeDrag={effectivePermissions.canEdit}
/>
<CollaboratorCursorLayer />
<Cursors />
{/* Floating chat modal */}
<Chat />
@@ -2088,9 +2097,7 @@ const WorkflowContent = React.memo(() => {
/>
{/* Trigger list for empty workflows - only show after workflow has loaded and hydrated */}
{isWorkflowReady && isWorkflowEmpty && effectivePermissions.canEdit && (
<TriggerList onSelect={handleTriggerSelect} />
)}
{isWorkflowReady && isWorkflowEmpty && effectivePermissions.canEdit && <CommandList />}
<Panel />
</div>

View File

@@ -1,219 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
export interface NavigationSection {
id: string
name: string
type: 'grid' | 'list'
items: any[]
gridCols?: number // How many columns per row for grid sections
}
export interface NavigationPosition {
sectionIndex: number
itemIndex: number
}
export function useSearchNavigation(sections: NavigationSection[], isOpen: boolean) {
const [position, setPosition] = useState<NavigationPosition>({ sectionIndex: 0, itemIndex: 0 })
const scrollRefs = useRef<Map<string, HTMLElement>>(new Map())
const lastPositionInSection = useRef<Map<string, number>>(new Map())
// Reset position when sections change or modal opens
useEffect(() => {
if (sections.length > 0) {
setPosition({ sectionIndex: 0, itemIndex: 0 })
}
}, [sections, isOpen])
const getCurrentItem = useCallback(() => {
if (sections.length === 0 || position.sectionIndex >= sections.length) return null
const section = sections[position.sectionIndex]
if (position.itemIndex >= section.items.length) return null
return {
section,
item: section.items[position.itemIndex],
position,
}
}, [sections, position])
const navigate = useCallback(
(direction: 'up' | 'down' | 'left' | 'right') => {
if (sections.length === 0) return
const currentSection = sections[position.sectionIndex]
if (!currentSection) return
const isGridSection = currentSection.type === 'grid'
const gridCols = currentSection.gridCols || 1
setPosition((prevPosition) => {
let newSectionIndex = prevPosition.sectionIndex
let newItemIndex = prevPosition.itemIndex
if (direction === 'up') {
if (isGridSection) {
// In grid: up moves to previous row in same section, or previous section
if (newItemIndex >= gridCols) {
newItemIndex -= gridCols
} else if (newSectionIndex > 0) {
// Save current position before moving to previous section
lastPositionInSection.current.set(currentSection.id, newItemIndex)
// Move to previous section
newSectionIndex -= 1
const prevSection = sections[newSectionIndex]
// Restore last position in that section, or go to end
const lastPos = lastPositionInSection.current.get(prevSection.id)
if (lastPos !== undefined && lastPos < prevSection.items.length) {
newItemIndex = lastPos
} else {
newItemIndex = Math.max(0, prevSection.items.length - 1)
}
}
} else {
// In list: up moves to previous item, or previous section
if (newItemIndex > 0) {
newItemIndex -= 1
} else if (newSectionIndex > 0) {
// Save current position before moving to previous section
lastPositionInSection.current.set(currentSection.id, newItemIndex)
newSectionIndex -= 1
const prevSection = sections[newSectionIndex]
// Restore last position in that section, or go to end
const lastPos = lastPositionInSection.current.get(prevSection.id)
if (lastPos !== undefined && lastPos < prevSection.items.length) {
newItemIndex = lastPos
} else {
newItemIndex = Math.max(0, prevSection.items.length - 1)
}
}
}
} else if (direction === 'down') {
if (isGridSection) {
// In grid: down moves to next row in same section, or next section
const maxIndexInCurrentRow = Math.min(
newItemIndex + gridCols,
currentSection.items.length - 1
)
if (newItemIndex + gridCols < currentSection.items.length) {
newItemIndex += gridCols
} else if (newSectionIndex < sections.length - 1) {
// Save current position before moving to next section
lastPositionInSection.current.set(currentSection.id, newItemIndex)
// Move to next section
newSectionIndex += 1
const nextSection = sections[newSectionIndex]
// Restore last position in next section, or start at beginning
const lastPos = lastPositionInSection.current.get(nextSection.id)
if (lastPos !== undefined && lastPos < nextSection.items.length) {
newItemIndex = lastPos
} else {
newItemIndex = 0
}
}
} else {
// In list: down moves to next item, or next section
if (newItemIndex < currentSection.items.length - 1) {
newItemIndex += 1
} else if (newSectionIndex < sections.length - 1) {
// Save current position before moving to next section
lastPositionInSection.current.set(currentSection.id, newItemIndex)
newSectionIndex += 1
const nextSection = sections[newSectionIndex]
// Restore last position in next section, or start at beginning
const lastPos = lastPositionInSection.current.get(nextSection.id)
if (lastPos !== undefined && lastPos < nextSection.items.length) {
newItemIndex = lastPos
} else {
newItemIndex = 0
}
}
}
} else if (direction === 'left' && isGridSection) {
// In grid: left moves to previous item in same row
if (newItemIndex > 0) {
const currentRow = Math.floor(newItemIndex / gridCols)
const newIndex = newItemIndex - 1
const newRow = Math.floor(newIndex / gridCols)
// Only move if we stay in the same row
if (currentRow === newRow) {
newItemIndex = newIndex
}
}
} else if (direction === 'right' && isGridSection) {
// In grid: right moves to next item in same row
if (newItemIndex < currentSection.items.length - 1) {
const currentRow = Math.floor(newItemIndex / gridCols)
const newIndex = newItemIndex + 1
const newRow = Math.floor(newIndex / gridCols)
// Only move if we stay in the same row
if (currentRow === newRow) {
newItemIndex = newIndex
}
}
}
return { sectionIndex: newSectionIndex, itemIndex: newItemIndex }
})
},
[sections, position]
)
// Scroll selected item into view
useEffect(() => {
const current = getCurrentItem()
if (!current) return
const { section, position: currentPos } = current
const scrollContainer = scrollRefs.current.get(section.id)
if (scrollContainer) {
const itemElement = scrollContainer.querySelector(
`[data-nav-item="${section.id}-${currentPos.itemIndex}"]`
) as HTMLElement
if (itemElement) {
// For horizontal scrolling sections (blocks/tools)
if (section.type === 'grid') {
const containerRect = scrollContainer.getBoundingClientRect()
const itemRect = itemElement.getBoundingClientRect()
// Check if item is outside the visible area horizontally
if (itemRect.left < containerRect.left) {
scrollContainer.scrollTo({
left: scrollContainer.scrollLeft - (containerRect.left - itemRect.left + 20),
behavior: 'smooth',
})
} else if (itemRect.right > containerRect.right) {
scrollContainer.scrollTo({
left: scrollContainer.scrollLeft + (itemRect.right - containerRect.right + 20),
behavior: 'smooth',
})
}
}
// Always ensure vertical visibility
itemElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
}
}, [getCurrentItem, position])
return {
navigate,
getCurrentItem,
scrollRefs,
position,
}
}

View File

@@ -1,4 +1,6 @@
export { FooterNavigation } from './footer-navigation'
export { HelpModal } from './help-modal'
export { SearchModal } from './search-modal'
export { SettingsModal } from './settings-modal'
export { WorkflowList } from './workflow-list/workflow-list'
export { WorkspaceHeader } from './workspace-header'

View File

@@ -0,0 +1 @@
export { SearchModal } from './search-modal'

View File

@@ -0,0 +1,599 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
import { BookOpen, Layout, RepeatIcon, ScrollText, Search, SplitIcon } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { Dialog, DialogOverlay, DialogPortal, DialogTitle } from '@/components/ui/dialog'
import { useBrandConfig } from '@/lib/branding/branding'
import { cn } from '@/lib/utils'
import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/trigger-utils'
import { getAllBlocks } from '@/blocks'
interface SearchModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workflows?: WorkflowItem[]
workspaces?: WorkspaceItem[]
isOnWorkflowPage?: boolean
}
interface WorkflowItem {
id: string
name: string
href: string
color: string
isCurrent?: boolean
}
interface WorkspaceItem {
id: string
name: string
href: string
isCurrent?: boolean
}
interface BlockItem {
id: string
name: string
description: string
icon: React.ComponentType<any>
bgColor: string
type: string
config?: any
}
interface ToolItem {
id: string
name: string
description: string
icon: React.ComponentType<any>
bgColor: string
type: string
}
interface PageItem {
id: string
name: string
icon: React.ComponentType<any>
href: string
shortcut?: string
}
interface DocItem {
id: string
name: string
icon: React.ComponentType<any>
href: string
type: 'main' | 'block' | 'tool'
}
type SearchItem = {
id: string
name: string
description?: string
icon?: React.ComponentType<any>
bgColor?: string
color?: string
href?: string
shortcut?: string
type: 'block' | 'trigger' | 'tool' | 'workflow' | 'workspace' | 'page' | 'doc'
isCurrent?: boolean
blockType?: string
config?: any
}
export function SearchModal({
open,
onOpenChange,
workflows = [],
workspaces = [],
isOnWorkflowPage = false,
}: SearchModalProps) {
const [searchQuery, setSearchQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
const brand = useBrandConfig()
// Get all available blocks - only when on workflow page
const blocks = useMemo(() => {
if (!isOnWorkflowPage) return []
const allBlocks = getAllBlocks()
const regularBlocks = allBlocks
.filter(
(block) => block.type !== 'starter' && !block.hideFromToolbar && block.category === 'blocks'
)
.map(
(block): BlockItem => ({
id: block.type,
name: block.name,
description: block.description || '',
icon: block.icon,
bgColor: block.bgColor || '#6B7280',
type: block.type,
})
)
// Add special blocks (loop and parallel)
const specialBlocks: BlockItem[] = [
{
id: 'loop',
name: 'Loop',
description: 'Create a Loop',
icon: RepeatIcon,
bgColor: '#2FB3FF',
type: 'loop',
},
{
id: 'parallel',
name: 'Parallel',
description: 'Parallel Execution',
icon: SplitIcon,
bgColor: '#FEE12B',
type: 'parallel',
},
]
return [...regularBlocks, ...specialBlocks]
}, [isOnWorkflowPage])
const triggers = useMemo(() => {
if (!isOnWorkflowPage) return []
const allTriggers = getTriggersForSidebar()
const priorityOrder = ['Start', 'Schedule', 'Webhook']
// Sort triggers with priority order matching toolbar
const sortedTriggers = allTriggers.sort((a, b) => {
const aIndex = priorityOrder.indexOf(a.name)
const bIndex = priorityOrder.indexOf(b.name)
const aHasPriority = aIndex !== -1
const bHasPriority = bIndex !== -1
if (aHasPriority && bHasPriority) return aIndex - bIndex
if (aHasPriority) return -1
if (bHasPriority) return 1
return a.name.localeCompare(b.name)
})
return sortedTriggers.map(
(block): BlockItem => ({
id: block.type,
name: block.name,
description: block.description || '',
icon: block.icon,
bgColor: block.bgColor || '#6B7280',
type: block.type,
config: block,
})
)
}, [isOnWorkflowPage])
// Get all available tools - only when on workflow page
const tools = useMemo(() => {
if (!isOnWorkflowPage) return []
const allBlocks = getAllBlocks()
return allBlocks
.filter((block) => block.category === 'tools')
.map(
(block): ToolItem => ({
id: block.type,
name: block.name,
description: block.description || '',
icon: block.icon,
bgColor: block.bgColor || '#6B7280',
type: block.type,
})
)
}, [isOnWorkflowPage])
// Define pages
const pages = useMemo(
(): PageItem[] => [
{
id: 'logs',
name: 'Logs',
icon: ScrollText,
href: `/workspace/${workspaceId}/logs`,
shortcut: '⌘⇧L',
},
{
id: 'templates',
name: 'Templates',
icon: Layout,
href: `/workspace/${workspaceId}/templates`,
},
{
id: 'docs',
name: 'Docs',
icon: BookOpen,
href: brand.documentationUrl || 'https://docs.sim.ai/',
},
],
[workspaceId, brand.documentationUrl]
)
// Define docs
const docs = useMemo((): DocItem[] => {
const allBlocks = getAllBlocks()
const docsItems: DocItem[] = []
allBlocks.forEach((block) => {
if (block.docsLink) {
docsItems.push({
id: `docs-${block.type}`,
name: block.name,
icon: block.icon,
href: block.docsLink,
type: block.category === 'blocks' || block.category === 'triggers' ? 'block' : 'tool',
})
}
})
return docsItems
}, [])
// Combine all items into a single flattened list
const allItems = useMemo((): SearchItem[] => {
const items: SearchItem[] = []
// Add workspaces
workspaces.forEach((workspace) => {
items.push({
id: workspace.id,
name: workspace.name,
href: workspace.href,
type: 'workspace',
isCurrent: workspace.isCurrent,
})
})
// Add workflows
workflows.forEach((workflow) => {
items.push({
id: workflow.id,
name: workflow.name,
href: workflow.href,
type: 'workflow',
color: workflow.color,
isCurrent: workflow.isCurrent,
})
})
// Add pages
pages.forEach((page) => {
items.push({
id: page.id,
name: page.name,
icon: page.icon,
href: page.href,
shortcut: page.shortcut,
type: 'page',
})
})
// Add blocks
blocks.forEach((block) => {
items.push({
id: block.id,
name: block.name,
description: block.description,
icon: block.icon,
bgColor: block.bgColor,
type: 'block',
blockType: block.type,
})
})
// Add triggers
triggers.forEach((trigger) => {
items.push({
id: trigger.id,
name: trigger.name,
description: trigger.description,
icon: trigger.icon,
bgColor: trigger.bgColor,
type: 'trigger',
blockType: trigger.type,
config: trigger.config,
})
})
// Add tools
tools.forEach((tool) => {
items.push({
id: tool.id,
name: tool.name,
description: tool.description,
icon: tool.icon,
bgColor: tool.bgColor,
type: 'tool',
blockType: tool.type,
})
})
// Add docs
docs.forEach((doc) => {
items.push({
id: doc.id,
name: doc.name,
icon: doc.icon,
href: doc.href,
type: 'doc',
})
})
return items
}, [workspaces, workflows, pages, blocks, triggers, tools, docs])
// Filter items based on search query
const filteredItems = useMemo(() => {
if (!searchQuery.trim()) return allItems
const query = searchQuery.toLowerCase()
return allItems.filter(
(item) =>
item.name.toLowerCase().includes(query) || item.description?.toLowerCase().includes(query)
)
}, [allItems, searchQuery])
// Reset selected index when filtered items change
useEffect(() => {
setSelectedIndex(0)
}, [filteredItems])
// Clear search when modal closes
useEffect(() => {
if (!open) {
setSearchQuery('')
setSelectedIndex(0)
}
}, [open])
// Handle item selection
const handleItemClick = useCallback(
(item: SearchItem) => {
switch (item.type) {
case 'block':
case 'trigger':
case 'tool':
if (item.blockType) {
const enableTriggerMode =
item.type === 'trigger' && item.config ? hasTriggerCapability(item.config) : false
const event = new CustomEvent('add-block-from-toolbar', {
detail: {
type: item.blockType,
enableTriggerMode,
},
})
window.dispatchEvent(event)
}
break
case 'workspace':
case 'workflow':
case 'page':
case 'doc':
if (item.href) {
if (item.href.startsWith('http')) {
window.open(item.href, '_blank', 'noopener,noreferrer')
} else {
router.push(item.href)
}
}
break
}
onOpenChange(false)
},
[router, onOpenChange]
)
// Handle keyboard navigation
useEffect(() => {
if (!open) return
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex((prev) => Math.min(prev + 1, filteredItems.length - 1))
break
case 'ArrowUp':
e.preventDefault()
setSelectedIndex((prev) => Math.max(prev - 1, 0))
break
case 'Enter':
e.preventDefault()
if (filteredItems[selectedIndex]) {
handleItemClick(filteredItems[selectedIndex])
}
break
case 'Escape':
e.preventDefault()
onOpenChange(false)
break
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open, selectedIndex, filteredItems, handleItemClick, onOpenChange])
// Scroll selected item into view
useEffect(() => {
if (open && selectedIndex >= 0) {
const element = document.querySelector(`[data-search-item-index="${selectedIndex}"]`)
if (element) {
element.scrollIntoView({ block: 'nearest' })
}
}
}, [selectedIndex, open])
// Group items by type for sectioned display
const groupedItems = useMemo(() => {
const groups: Record<string, SearchItem[]> = {
workspace: [],
workflow: [],
page: [],
trigger: [],
block: [],
tool: [],
doc: [],
}
filteredItems.forEach((item) => {
if (groups[item.type]) {
groups[item.type].push(item)
}
})
return groups
}, [filteredItems])
// Section titles mapping
const sectionTitles: Record<string, string> = {
workspace: 'Workspaces',
workflow: 'Workflows',
page: 'Pages',
trigger: 'Triggers',
block: 'Blocks',
tool: 'Tools',
doc: 'Documentation',
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogPortal>
<DialogOverlay
className='bg-white/80 dark:bg-transparent'
style={{ backdropFilter: 'blur(3px)' }}
/>
<DialogPrimitive.Content className='fixed top-[15%] left-[50%] z-50 flex w-[500px] translate-x-[-50%] flex-col gap-[12px] p-0 focus:outline-none focus-visible:outline-none'>
<VisuallyHidden.Root>
<DialogTitle>Search</DialogTitle>
</VisuallyHidden.Root>
{/* Search input container */}
<div className='flex items-center gap-[6px] rounded-[10px] border border-[#2C2C2C] bg-[#272727] px-[10px] py-[8px] shadow-sm dark:border-[#2C2C2C] dark:bg-[#272727]'>
<Search className='h-[16px] w-[16px] flex-shrink-0 text-[#7D7D7D] dark:text-[#7D7D7D]' />
<input
type='text'
placeholder='Search anything...'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className='w-full border-0 bg-transparent font-base text-[#E6E6E6] text-[16px] placeholder:text-[#B1B1B1] focus:outline-none dark:text-[#E6E6E6] dark:placeholder:text-[#B1B1B1]'
autoFocus
/>
</div>
{/* Floating results container */}
{filteredItems.length > 0 ? (
<div className='scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent max-h-[400px] overflow-y-auto rounded-[10px] py-[8px] shadow-sm backdrop-blur-lg'>
{Object.entries(groupedItems).map(([type, items]) => {
if (items.length === 0) return null
return (
<div key={type} className='mb-[10px] last:mb-0'>
{/* Section header */}
<div className='pt-[2px] pb-[4px] font-medium text-[#7D7D7D] text-[11px] uppercase tracking-wide dark:text-[#7D7D7D]'>
{sectionTitles[type]}
</div>
{/* Section items */}
<div className='space-y-[2px]'>
{items.map((item, itemIndex) => {
const Icon = item.icon
const globalIndex = filteredItems.indexOf(item)
const isSelected = globalIndex === selectedIndex
const showColoredIcon =
item.type === 'block' || item.type === 'trigger' || item.type === 'tool'
const isWorkflow = item.type === 'workflow'
const isWorkspace = item.type === 'workspace'
return (
<button
key={`${item.type}-${item.id}`}
data-search-item-index={globalIndex}
onClick={() => handleItemClick(item)}
className={cn(
'group flex h-[28px] w-full items-center gap-[8px] rounded-[6px] bg-[#252525]/60 px-[8px] text-left text-[13px] transition-all focus:outline-none dark:bg-[#252525]/60',
isSelected
? 'bg-[#2C2C2C] shadow-sm dark:bg-[#2C2C2C]'
: 'hover:bg-[#2C2C2C] dark:hover:bg-[#2C2C2C]'
)}
>
{/* Icon - different rendering for workflows vs others */}
{!isWorkspace && (
<>
{isWorkflow ? (
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px]'
style={{ backgroundColor: item.color }}
/>
) : (
Icon && (
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{
backgroundColor: showColoredIcon
? item.bgColor
: 'transparent',
}}
>
<Icon
className={cn(
'transition-transform duration-100 group-hover:scale-110',
showColoredIcon
? '!h-[10px] !w-[10px] text-white'
: 'h-[14px] w-[14px] text-[#AEAEAE] group-hover:text-[#E6E6E6] dark:text-[#AEAEAE] dark:group-hover:text-[#E6E6E6]'
)}
/>
</div>
)
)}
</>
)}
{/* Content */}
<span
className={cn(
'truncate font-medium',
isSelected
? 'text-[#E6E6E6] dark:text-[#E6E6E6]'
: 'text-[#AEAEAE] group-hover:text-[#E6E6E6] dark:text-[#AEAEAE] dark:group-hover:text-[#E6E6E6]'
)}
>
{item.name}
{item.isCurrent && ' (current)'}
</span>
{/* Shortcut */}
{item.shortcut && (
<span className='ml-auto flex-shrink-0 font-medium text-[#7D7D7D] text-[11px] dark:text-[#7D7D7D]'>
{item.shortcut}
</span>
)}
</button>
)
})}
</div>
</div>
)
})}
</div>
) : searchQuery ? (
<div className='flex items-center justify-center rounded-[10px] bg-[#272727] px-[16px] py-[24px] shadow-sm dark:bg-[#272727]'>
<p className='text-[#7D7D7D] text-[13px] dark:text-[#7D7D7D]'>
No results found for "{searchQuery}"
</p>
</div>
) : null}
</DialogPrimitive.Content>
</DialogPortal>
</Dialog>
)
}

View File

@@ -30,7 +30,7 @@ interface DeleteModalProps {
/**
* Type of item being deleted
*/
itemType: 'workflow' | 'folder'
itemType: 'workflow' | 'folder' | 'workspace'
/**
* Name(s) of the item(s) being deleted (optional, for display)
* Can be a single name or an array of names for multiple items
@@ -39,7 +39,7 @@ interface DeleteModalProps {
}
/**
* Reusable delete confirmation modal for workflow and folder items.
* Reusable delete confirmation modal for workflow, folder, and workspace items.
* Displays a warning message and confirmation buttons.
*
* @param props - Component props
@@ -61,8 +61,10 @@ export function DeleteModal({
let title = ''
if (itemType === 'workflow') {
title = isMultiple ? 'Delete workflows?' : 'Delete workflow?'
} else {
} else if (itemType === 'folder') {
title = 'Delete folder?'
} else {
title = 'Delete workspace?'
}
let description = ''
@@ -76,13 +78,16 @@ export function DeleteModal({
description =
'Deleting this workflow will permanently remove all associated blocks, executions, and configuration.'
}
} else {
} else if (itemType === 'folder') {
if (isSingle && displayNames.length > 0) {
description = `Deleting ${displayNames[0]} will permanently remove all associated workflows, logs, and knowledge bases.`
} else {
description =
'Deleting this folder will permanently remove all associated workflows, logs, and knowledge bases.'
}
} else {
description =
'Deleting this workspace will permanently remove all associated workflows, folders, logs, and knowledge bases.'
}
return (

View File

@@ -0,0 +1,135 @@
'use client'
import { type CSSProperties, useEffect, useMemo } from 'react'
import Image from 'next/image'
import { Tooltip } from '@/components/emcn'
import { useSession } from '@/lib/auth-client'
import { getUserColor } from '@/app/workspace/[workspaceId]/w/utils/get-user-color'
import { useSocket } from '@/contexts/socket-context'
interface AvatarsProps {
workflowId: string
maxVisible?: number
/**
* Callback fired when the presence visibility changes.
* Used by parent components to adjust layout (e.g., text truncation spacing).
*/
onPresenceChange?: (hasAvatars: boolean) => void
}
/**
* Displays user avatars for presence in a workflow item.
* Consolidated logic from user-avatar-stack and user-avatar components.
* Only shows avatars for the currently active workflow.
*
* @param props - Component props
* @returns Avatar stack for workflow presence
*/
export function Avatars({ workflowId, maxVisible = 3, onPresenceChange }: AvatarsProps) {
const { presenceUsers, currentWorkflowId } = useSocket()
const { data: session } = useSession()
const currentUserId = session?.user?.id
/**
* Only show presence for the currently active workflow
* Filter out the current user from the list
*/
const workflowUsers = useMemo(() => {
if (currentWorkflowId !== workflowId) {
return []
}
return presenceUsers.filter((user) => user.userId !== currentUserId)
}, [presenceUsers, currentWorkflowId, workflowId, currentUserId])
/**
* Calculate visible users and overflow count
*/
const { visibleUsers, overflowCount } = useMemo(() => {
if (workflowUsers.length === 0) {
return { visibleUsers: [], overflowCount: 0 }
}
const visible = workflowUsers.slice(0, maxVisible)
const overflow = Math.max(0, workflowUsers.length - maxVisible)
return { visibleUsers: visible, overflowCount: overflow }
}, [workflowUsers, maxVisible])
// Notify parent when avatars are present or not
useEffect(() => {
const hasAnyAvatars = visibleUsers.length > 0
if (typeof onPresenceChange === 'function') {
onPresenceChange(hasAnyAvatars)
}
}, [visibleUsers, onPresenceChange])
if (visibleUsers.length === 0) {
return null
}
return (
<div className='-space-x-1 ml-[-8px] flex items-center'>
{visibleUsers.map((user, index) => {
const color = getUserColor(user.userId)
const initials = user.userName ? user.userName.charAt(0).toUpperCase() : '?'
const hasAvatar = Boolean(user.avatarUrl)
const avatarElement = (
<div
key={user.socketId}
className='relative flex h-[14px] w-[14px] flex-shrink-0 cursor-default items-center justify-center overflow-hidden rounded-full font-semibold text-[7px] text-white'
style={
{
background: hasAvatar ? undefined : color,
zIndex: 10 - index,
} as CSSProperties
}
>
{hasAvatar && user.avatarUrl ? (
<Image
src={user.avatarUrl}
alt={user.userName ? `${user.userName}'s avatar` : 'User avatar'}
fill
sizes='14px'
className='object-cover'
referrerPolicy='no-referrer'
unoptimized={user.avatarUrl.startsWith('http')}
/>
) : (
initials
)}
</div>
)
if (user.userName) {
return (
<Tooltip.Root key={user.socketId}>
<Tooltip.Trigger asChild>{avatarElement}</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
<span>{user.userName}</span>
</Tooltip.Content>
</Tooltip.Root>
)
}
return avatarElement
})}
{overflowCount > 0 && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div
className='relative flex h-[14px] w-[14px] flex-shrink-0 cursor-default items-center justify-center overflow-hidden rounded-full bg-[#404040] font-semibold text-[7px] text-white'
style={{ zIndex: 10 - visibleUsers.length } as CSSProperties}
>
+{overflowCount}
</div>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
{overflowCount} more user{overflowCount > 1 ? 's' : ''}
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
)
}

View File

@@ -6,6 +6,7 @@ import Link from 'next/link'
import { useParams } from 'next/navigation'
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/context-menu/context-menu'
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/delete-modal/delete-modal'
import { Avatars } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/workflow-item/avatars/avatars'
import {
useContextMenu,
useItemDrag,
@@ -42,6 +43,9 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
const [workflowIdsToDelete, setWorkflowIdsToDelete] = useState<string[]>([])
const [deleteModalNames, setDeleteModalNames] = useState<string | string[]>('')
// Presence avatars state
const [hasAvatars, setHasAvatars] = useState(false)
// Capture selection at right-click time (using ref to persist across renders)
const capturedSelectionRef = useRef<{
workflowIds: string[]
@@ -221,41 +225,46 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
className='h-[14px] w-[14px] flex-shrink-0 rounded-[4px]'
style={{ backgroundColor: workflow.color }}
/>
{isEditing ? (
<input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleInputBlur}
className={clsx(
'min-w-0 flex-1 border-0 bg-transparent p-0 font-medium text-[14px] outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
active
? 'text-[#E6E6E6] dark:text-[#E6E6E6]'
: 'text-[#AEAEAE] group-hover:text-[#E6E6E6] dark:text-[#AEAEAE] dark:group-hover:text-[#E6E6E6]'
)}
maxLength={100}
disabled={isRenaming}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
/>
) : (
<span
className={clsx(
'truncate font-medium',
active
? 'text-[#E6E6E6] dark:text-[#E6E6E6]'
: 'text-[#AEAEAE] group-hover:text-[#E6E6E6] dark:text-[#AEAEAE] dark:group-hover:text-[#E6E6E6]'
)}
>
{workflow.name}
</span>
<div className={clsx('min-w-0 flex-1', hasAvatars && 'pr-[8px]')}>
{isEditing ? (
<input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleInputBlur}
className={clsx(
'w-full border-0 bg-transparent p-0 font-medium text-[14px] outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
active
? 'text-[#E6E6E6] dark:text-[#E6E6E6]'
: 'text-[#AEAEAE] group-hover:text-[#E6E6E6] dark:text-[#AEAEAE] dark:group-hover:text-[#E6E6E6]'
)}
maxLength={100}
disabled={isRenaming}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
/>
) : (
<div
className={clsx(
'truncate font-medium',
active
? 'text-[#E6E6E6] dark:text-[#E6E6E6]'
: 'text-[#AEAEAE] group-hover:text-[#E6E6E6] dark:text-[#AEAEAE] dark:group-hover:text-[#E6E6E6]'
)}
>
{workflow.name}
</div>
)}
</div>
{!isEditing && (
<Avatars workflowId={workflow.id} maxVisible={3} onPresenceChange={setHasAvatars} />
)}
</Link>

View File

@@ -0,0 +1 @@
export { InviteModal } from './invite-modal/invite-modal'

View File

@@ -1068,7 +1068,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
<label htmlFor='emails' className='font-medium text-sm'>
Email Addresses
</label>
<div className='scrollbar-hide flex max-h-32 min-h-9 flex-wrap items-center gap-x-2 gap-y-1 overflow-y-auto rounded-[8px] border px-2 py-1 focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background'>
<div className='scrollbar-hide flex max-h-32 min-h-9 flex-wrap items-center gap-x-2 gap-y-1 overflow-y-auto rounded-[8px] border border-input bg-background px-2 py-1 focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2'>
{invalidEmails.map((email, index) => (
<EmailTag
key={`invalid-${index}`}
@@ -1102,7 +1102,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
: 'Enter emails'
}
className={cn(
'h-6 min-w-[180px] flex-1 border-none focus-visible:ring-0 focus-visible:ring-offset-0',
'h-6 min-w-[180px] flex-1 border-none bg-transparent p-0 focus-visible:ring-0 focus-visible:ring-offset-0',
emails.length > 0 || invalidEmails.length > 0 ? 'pl-1' : 'pl-1'
)}
autoFocus={userPerms.canAdmin}

View File

@@ -0,0 +1 @@
export { WorkspaceHeader } from './workspace-header'

View File

@@ -0,0 +1,393 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Pencil, Plus, RefreshCw, Settings } from 'lucide-react'
import {
Badge,
Button,
ChevronDown,
PanelLeft,
Popover,
PopoverContent,
PopoverItem,
PopoverSection,
PopoverTrigger,
Tooltip,
Trash,
} from '@/components/emcn'
import { createLogger } from '@/lib/logs/console/logger'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { DeleteModal } from '../workflow-list/components/delete-modal/delete-modal'
import { InviteModal } from './components'
const logger = createLogger('WorkspaceHeader')
interface Workspace {
id: string
name: string
ownerId: string
role?: string
}
interface WorkspaceHeaderProps {
/**
* The active workspace object
*/
activeWorkspace?: { name: string } | null
/**
* Current workspace ID
*/
workspaceId: string
/**
* List of available workspaces
*/
workspaces: Workspace[]
/**
* Whether workspaces are loading
*/
isWorkspacesLoading: boolean
/**
* Whether workspace creation is in progress
*/
isCreatingWorkspace: boolean
/**
* Whether the workspace menu popover is open
*/
isWorkspaceMenuOpen: boolean
/**
* Callback to set workspace menu open state
*/
setIsWorkspaceMenuOpen: (isOpen: boolean) => void
/**
* Callback when workspace is switched
*/
onWorkspaceSwitch: (workspace: Workspace) => void
/**
* Callback when create workspace is clicked
*/
onCreateWorkspace: () => Promise<void>
/**
* Callback when toggle collapse is clicked
*/
onToggleCollapse: () => void
/**
* Whether the sidebar is collapsed
*/
isCollapsed: boolean
/**
* Callback to rename the workspace
*/
onRenameWorkspace: (workspaceId: string, newName: string) => Promise<void>
/**
* Callback to delete the workspace
*/
onDeleteWorkspace: (workspaceId: string) => Promise<void>
}
/**
* Workspace header component that displays workspace name, switcher, and collapse toggle.
* Used in both the full sidebar and floating collapsed state.
*/
export function WorkspaceHeader({
activeWorkspace,
workspaceId,
workspaces,
isWorkspacesLoading,
isCreatingWorkspace,
isWorkspaceMenuOpen,
setIsWorkspaceMenuOpen,
onWorkspaceSwitch,
onCreateWorkspace,
onToggleCollapse,
isCollapsed,
onRenameWorkspace,
onDeleteWorkspace,
}: WorkspaceHeaderProps) {
const userPermissions = useUserPermissionsContext()
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false)
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<Workspace | null>(null)
const [settingsWorkspaceId, setSettingsWorkspaceId] = useState<string | null>(null)
const [editingWorkspaceId, setEditingWorkspaceId] = useState<string | null>(null)
const [editingName, setEditingName] = useState('')
const [isListRenaming, setIsListRenaming] = useState(false)
const listRenameInputRef = useRef<HTMLInputElement | null>(null)
/**
* Focus the inline list rename input when it becomes active
*/
useEffect(() => {
if (editingWorkspaceId && listRenameInputRef.current) {
try {
listRenameInputRef.current.focus()
listRenameInputRef.current.select()
} catch {
// no-op
}
}
}, [editingWorkspaceId])
/**
* Save and exit edit mode when popover closes
*/
useEffect(() => {
if (!isWorkspaceMenuOpen && editingWorkspaceId) {
const workspace = workspaces.find((w) => w.id === editingWorkspaceId)
if (workspace && editingName.trim() && editingName.trim() !== workspace.name) {
void onRenameWorkspace(editingWorkspaceId, editingName.trim())
}
setEditingWorkspaceId(null)
}
}, [isWorkspaceMenuOpen, editingWorkspaceId, editingName, workspaces, onRenameWorkspace])
const activeWorkspaceFull = workspaces.find((w) => w.id === workspaceId) || null
/**
* Handles page refresh when disconnected
*/
const handleRefresh = () => {
window.location.reload()
}
/**
* Handles rename action from settings menu
*/
const handleRenameAction = (workspace: Workspace) => {
setSettingsWorkspaceId(null)
setEditingWorkspaceId(workspace.id)
setEditingName(workspace.name)
}
/**
* Handles delete action from settings menu
*/
const handleDeleteAction = (workspace: Workspace) => {
setSettingsWorkspaceId(null)
setDeleteTarget(workspace)
setIsDeleteModalOpen(true)
}
/**
* Handle delete workspace
*/
const handleDeleteWorkspace = async () => {
setIsDeleting(true)
try {
const targetId = deleteTarget?.id || workspaceId
await onDeleteWorkspace(targetId)
setIsDeleteModalOpen(false)
setDeleteTarget(null)
} catch (error) {
logger.error('Error deleting workspace:', error)
} finally {
setIsDeleting(false)
}
}
return (
<div className='flex min-w-0 items-center justify-between gap-[8px]'>
{/* Workspace Name */}
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<h2
className='max-w-full truncate font-base text-[14px] dark:text-[#FFFFFF]'
title={activeWorkspace?.name || 'Loading...'}
>
{activeWorkspace?.name || 'Loading...'}
</h2>
</div>
{/* Workspace Actions */}
<div className='flex items-center gap-[10px]'>
{/* Disconnection Indicator */}
{userPermissions.isOfflineMode && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
type='button'
aria-label='Connection lost - click to refresh'
className='group !p-[3px] -m-[3px]'
onClick={handleRefresh}
>
<RefreshCw className='h-[14px] w-[14px] text-[#EF4444] dark:text-[#EF4444]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Connection lost - refresh</Tooltip.Content>
</Tooltip.Root>
)}
{/* Invite */}
<Badge className='cursor-pointer' onClick={() => setIsInviteModalOpen(true)}>
Invite
</Badge>
{/* Workspace Switcher Popover */}
<Popover open={isWorkspaceMenuOpen} onOpenChange={setIsWorkspaceMenuOpen}>
<PopoverTrigger asChild>
<Button
variant='ghost-secondary'
type='button'
aria-label='Switch workspace'
className='group !p-[3px] -m-[3px]'
>
<ChevronDown
className={`h-[8px] w-[12px] transition-transform duration-100 ${
isWorkspaceMenuOpen ? 'rotate-180' : ''
}`}
/>
</Button>
</PopoverTrigger>
<PopoverContent
align='end'
side='bottom'
sideOffset={8}
style={{ maxWidth: '160px', minWidth: '160px' }}
>
{isWorkspacesLoading ? (
<PopoverItem disabled>
<span>Loading workspaces...</span>
</PopoverItem>
) : (
<>
<div className='relative flex items-center justify-between'>
<PopoverSection>Workspaces</PopoverSection>
<Button
variant='ghost'
type='button'
aria-label='Create workspace'
className='!p-[3px] absolute top-[3px] right-[5.5px]'
onClick={async (e) => {
e.stopPropagation()
await onCreateWorkspace()
setIsWorkspaceMenuOpen(false)
}}
disabled={isCreatingWorkspace}
>
<Plus className='h-[14px] w-[14px]' />
</Button>
</div>
<div className='max-h-[200px] overflow-y-auto'>
{workspaces.map((workspace, index) => (
<div key={workspace.id} className={index > 0 ? 'mt-[2px]' : ''}>
{editingWorkspaceId === workspace.id ? (
<div className='flex h-[25px] items-center gap-[8px] rounded-[6px] bg-[#363636] px-[6px] dark:bg-[#363636]'>
<input
ref={listRenameInputRef}
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={async (e) => {
if (e.key === 'Enter') {
e.preventDefault()
setIsListRenaming(true)
try {
await onRenameWorkspace(workspace.id, editingName.trim())
setEditingWorkspaceId(null)
} finally {
setIsListRenaming(false)
}
} else if (e.key === 'Escape') {
e.preventDefault()
setEditingWorkspaceId(null)
}
}}
onBlur={async () => {
if (!editingWorkspaceId) return
setIsListRenaming(true)
try {
await onRenameWorkspace(workspace.id, editingName.trim())
setEditingWorkspaceId(null)
} finally {
setIsListRenaming(false)
}
}}
className='w-full border-0 bg-transparent p-0 font-base text-[#E6E6E6] text-[12px] outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:text-[#E6E6E6]'
maxLength={100}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
disabled={isListRenaming}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
/>
</div>
) : (
<div className='group relative flex items-center'>
<PopoverItem
active={workspace.id === workspaceId}
onClick={() => onWorkspaceSwitch(workspace)}
className='flex-1 pr-[28px]'
>
<span className='min-w-0 flex-1 truncate'>{workspace.name}</span>
</PopoverItem>
<Popover
open={settingsWorkspaceId === workspace.id}
onOpenChange={(open) =>
setSettingsWorkspaceId(open ? workspace.id : null)
}
>
<PopoverTrigger asChild>
<Button
variant='ghost'
type='button'
aria-label='Workspace settings'
className='!p-[4px] absolute right-[4px]'
onClick={(e) => {
e.stopPropagation()
}}
>
<Settings className='h-[14px] w-[14px]' />
</Button>
</PopoverTrigger>
<PopoverContent align='end' side='right' sideOffset={10}>
<PopoverItem onClick={() => handleRenameAction(workspace)}>
<Pencil className='h-3 w-3' />
<span>Rename</span>
</PopoverItem>
<PopoverItem
onClick={() => handleDeleteAction(workspace)}
className='mt-[2px]'
>
<Trash className='h-3 w-3' />
<span>Delete</span>
</PopoverItem>
</PopoverContent>
</Popover>
</div>
)}
</div>
))}
</div>
</>
)}
</PopoverContent>
</Popover>
{/* Sidebar Collapse Toggle */}
<Button
variant='ghost-secondary'
type='button'
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
className='group !p-[3px] -m-[3px]'
onClick={onToggleCollapse}
>
<PanelLeft className='h-[17.5px] w-[17.5px]' />
</Button>
</div>
{/* Invite Modal */}
<InviteModal
open={isInviteModalOpen}
onOpenChange={setIsInviteModalOpen}
workspaceName={activeWorkspace?.name || 'Workspace'}
/>
{/* Delete Confirmation Modal */}
<DeleteModal
isOpen={isDeleteModalOpen}
onClose={() => setIsDeleteModalOpen(false)}
onConfirm={handleDeleteWorkspace}
isDeleting={isDeleting}
itemType='workspace'
itemName={deleteTarget?.name || activeWorkspaceFull?.name || activeWorkspace?.name}
/>
</div>
)
}

View File

@@ -1,6 +1,5 @@
import { HelpCircle, LibraryBig, ScrollText, Settings, Shapes } from 'lucide-react'
import { NavigationItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/navigation-item/navigation-item'
import { getKeyboardShortcutText } from '@/app/workspace/[workspaceId]/w/hooks/use-keyboard-shortcuts'
interface FloatingNavigationProps {
workspaceId: string
@@ -36,7 +35,6 @@ export const FloatingNavigation = ({
icon: ScrollText,
href: `/workspace/${workspaceId}/logs`,
tooltip: 'Logs',
shortcut: getKeyboardShortcutText('L', true, true),
active: pathname === `/workspace/${workspaceId}/logs`,
},
{

View File

@@ -21,7 +21,7 @@ import { isDev } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-selector/components/invite-modal/invite-modal'
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/workspace-header/components/invite-modal/invite-modal'
const logger = createLogger('WorkspaceSelector')

View File

@@ -15,7 +15,7 @@ interface UseItemRenameProps {
/**
* Item type for logging
*/
itemType: 'workflow' | 'folder'
itemType: 'workflow' | 'folder' | 'workspace'
/**
* Item ID for logging
*/
@@ -23,7 +23,7 @@ interface UseItemRenameProps {
}
/**
* Hook for managing inline rename functionality for workflows and folders.
* Hook for managing inline rename functionality for workflows, folders, and workspaces.
*
* Handles:
* - Edit state management

View File

@@ -1,29 +1,28 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ArrowDown, Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Button,
ChevronDown,
FolderPlus,
PanelLeft,
Popover,
PopoverContent,
PopoverItem,
PopoverSection,
PopoverTrigger,
Tooltip,
} from '@/components/emcn'
import { useParams, useRouter } from 'next/navigation'
import { Button, FolderPlus, Tooltip } from '@/components/emcn'
import { useSession } from '@/lib/auth-client'
import { useFolderStore } from '@/stores/folders/store'
import { FooterNavigation, WorkflowList } from './components-new'
import { createLogger } from '@/lib/logs/console/logger'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import {
FooterNavigation,
SearchModal,
WorkflowList,
WorkspaceHeader,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new'
import {
useFolderOperations,
useSidebarResize,
useWorkflowOperations,
useWorkspaceManagement,
} from './hooks'
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useFolderStore } from '@/stores/folders/store'
import { useSidebarStore } from '@/stores/sidebar/store'
const logger = createLogger('SidebarNew')
/**
* Sidebar component with resizable width that persists across page refreshes.
@@ -41,6 +40,7 @@ export function SidebarNew() {
const params = useParams()
const workspaceId = params.workspaceId as string
const workflowId = params.workflowId as string | undefined
const router = useRouter()
const sidebarRef = useRef<HTMLElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
@@ -49,12 +49,19 @@ export function SidebarNew() {
// Session data
const { data: sessionData, isPending: sessionLoading } = useSession()
// Sidebar state
const isCollapsed = useSidebarStore((state) => state.isCollapsed)
const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed)
// Import state
const [isImporting, setIsImporting] = useState(false)
// Workspace popover state
const [isWorkspaceMenuOpen, setIsWorkspaceMenuOpen] = useState(false)
// Search modal state
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false)
// Workspace management hook
const {
workspaces,
@@ -65,6 +72,8 @@ export function SidebarNew() {
switchWorkspace,
handleCreateWorkspace,
isCreatingWorkspace,
updateWorkspaceName,
confirmDeleteWorkspace,
} = useWorkspaceManagement({
workspaceId,
sessionUserId: sessionData?.user?.id,
@@ -90,6 +99,30 @@ export function SidebarNew() {
workspaceId,
})
// Prepare data for search modal
const searchModalWorkflows = useMemo(
() =>
regularWorkflows.map((workflow) => ({
id: workflow.id,
name: workflow.name,
href: `/workspace/${workspaceId}/w/${workflow.id}`,
color: workflow.color,
isCurrent: workflow.id === workflowId,
})),
[regularWorkflows, workspaceId, workflowId]
)
const searchModalWorkspaces = useMemo(
() =>
workspaces.map((workspace) => ({
id: workspace.id,
name: workspace.name,
href: `/workspace/${workspace.id}/w`,
isCurrent: workspace.id === workspaceId,
})),
[workspaces, workspaceId]
)
// Combined loading state
const isLoading = workflowsLoading || sessionLoading
@@ -198,6 +231,13 @@ export function SidebarNew() {
[workspaceId, switchWorkspace]
)
/**
* Handle sidebar collapse toggle
*/
const handleToggleCollapse = useCallback(() => {
setIsCollapsed(!isCollapsed)
}, [isCollapsed, setIsCollapsed])
/**
* Handle click on sidebar elements to revert to active workflow selection
*/
@@ -216,192 +256,278 @@ export function SidebarNew() {
[workflowId]
)
/**
* Handle workspace rename
*/
const handleRenameWorkspace = useCallback(
async (workspaceIdToRename: string, newName: string) => {
await updateWorkspaceName(workspaceIdToRename, newName)
},
[updateWorkspaceName]
)
/**
* Handle workspace delete
*/
const handleDeleteWorkspace = useCallback(
async (workspaceIdToDelete: string) => {
const workspaceToDelete = workspaces.find((w) => w.id === workspaceIdToDelete)
if (workspaceToDelete) {
await confirmDeleteWorkspace(workspaceToDelete, 'keep')
}
},
[workspaces, confirmDeleteWorkspace]
)
/**
* Register global commands:
* - Mod+Shift+A: Add an Agent block to the canvas
* - Mod+Y: Navigate to Templates (attempts to override browser history)
* - Mod+L: Navigate to Logs (attempts to override browser location bar)
* - Mod+K: Search (placeholder; no-op for now)
*/
useRegisterGlobalCommands(() => [
{
id: 'add-agent',
shortcut: 'Mod+Shift+A',
allowInEditable: true,
handler: () => {
try {
const event = new CustomEvent('add-block-from-toolbar', {
detail: { type: 'agent', enableTriggerMode: false },
})
window.dispatchEvent(event)
logger.info('Dispatched add-agent command')
} catch (err) {
logger.error('Failed to dispatch add-agent command', { err })
}
},
},
{
id: 'goto-templates',
shortcut: 'Mod+Y',
allowInEditable: true,
handler: () => {
try {
const pathWorkspaceId =
workspaceId ||
(typeof window !== 'undefined'
? (() => {
const parts = window.location.pathname.split('/')
const idx = parts.indexOf('workspace')
return idx !== -1 ? parts[idx + 1] : undefined
})()
: undefined)
if (pathWorkspaceId) {
router.push(`/workspace/${pathWorkspaceId}/templates`)
logger.info('Navigated to templates', { workspaceId: pathWorkspaceId })
} else {
router.push('/templates')
logger.info('Navigated to global templates (no workspace in path)')
}
} catch (err) {
logger.error('Failed to navigate to templates', { err })
}
},
},
{
id: 'goto-logs',
shortcut: 'Mod+L',
allowInEditable: true,
handler: () => {
try {
const pathWorkspaceId =
workspaceId ||
(typeof window !== 'undefined'
? (() => {
const parts = window.location.pathname.split('/')
const idx = parts.indexOf('workspace')
return idx !== -1 ? parts[idx + 1] : undefined
})()
: undefined)
if (pathWorkspaceId) {
router.push(`/workspace/${pathWorkspaceId}/logs`)
logger.info('Navigated to logs', { workspaceId: pathWorkspaceId })
} else {
logger.warn('No workspace ID found, cannot navigate to logs')
}
} catch (err) {
logger.error('Failed to navigate to logs', { err })
}
},
},
{
id: 'open-search',
shortcut: 'Mod+K',
allowInEditable: true,
handler: () => {
setIsSearchModalOpen(true)
logger.info('Search modal opened')
},
},
])
return (
<>
<aside
ref={sidebarRef}
className='sidebar-container fixed inset-y-0 left-0 z-10 overflow-hidden dark:bg-[#1E1E1E]'
aria-label='Workspace sidebar'
onClick={handleSidebarClick}
>
<div className='flex h-full flex-col border-r pt-[14px] dark:border-[#2C2C2C]'>
{/* Header */}
<div className='flex flex-shrink-0 items-center justify-between gap-[8px] px-[14px]'>
{/* Workspace Name */}
<div className='flex min-w-0 items-center gap-[8px]'>
<h2
className='truncate font-medium text-base dark:text-white'
title={activeWorkspace?.name || 'Loading...'}
>
{activeWorkspace?.name || 'Loading...'}
</h2>
{/* TODO: Solo/Team based on workspace members */}
{/* <Badge className='flex-shrink-0 translate-y-[1px] whitespace-nowrap'>Solo</Badge> */}
</div>
{/* Workspace Actions */}
<div className='flex items-center gap-[14px]'>
{/* Workspace Switcher Popover */}
<Popover open={isWorkspaceMenuOpen} onOpenChange={setIsWorkspaceMenuOpen}>
<PopoverTrigger asChild>
<Button
variant='ghost-secondary'
type='button'
aria-label='Switch workspace'
className='group -m-1 p-0 p-1'
>
<ChevronDown
className={`h-[8px] w-[12px] transition-transform duration-100 ${
isWorkspaceMenuOpen ? 'rotate-180' : ''
}`}
/>
</Button>
</PopoverTrigger>
<PopoverContent align='end' side='bottom' sideOffset={8}>
{isWorkspacesLoading ? (
<PopoverItem disabled>
<span>Loading workspaces...</span>
</PopoverItem>
) : (
<>
{workspaces.length > 0 && (
<>
<PopoverSection>Workspaces</PopoverSection>
{workspaces.map((workspace, index) => (
<PopoverItem
key={workspace.id}
active={workspace.id === workspaceId}
onClick={() => handleWorkspaceSwitch(workspace)}
className={index > 0 ? 'mt-[2px]' : ''}
>
<span>{workspace.name}</span>
</PopoverItem>
))}
</>
)}
<PopoverItem
onClick={async () => {
await handleCreateWorkspace()
setIsWorkspaceMenuOpen(false)
}}
disabled={isCreatingWorkspace}
className={workspaces.length > 0 ? 'mt-[2px]' : ''}
>
<Plus className='h-3 w-3' />
<span>Create a workspace</span>
</PopoverItem>
</>
)}
</PopoverContent>
</Popover>
{/* TODO: Add panel toggle */}
<Button
variant='ghost-secondary'
type='button'
aria-label='Toggle panel'
className='group p-0'
>
<PanelLeft className='h-[17.5px] w-[17.5px]' />
</Button>
</div>
</div>
{isCollapsed ? (
/* Floating collapsed header */
<div className='fixed top-[14px] left-[10px] z-10 max-w-[232px] rounded-[8px] border bg-white px-[12px] py-[8px] dark:border-[#2C2C2C] dark:bg-[#1E1E1E]'>
<WorkspaceHeader
activeWorkspace={activeWorkspace}
workspaceId={workspaceId}
workspaces={workspaces}
isWorkspacesLoading={isWorkspacesLoading}
isCreatingWorkspace={isCreatingWorkspace}
isWorkspaceMenuOpen={isWorkspaceMenuOpen}
setIsWorkspaceMenuOpen={setIsWorkspaceMenuOpen}
onWorkspaceSwitch={handleWorkspaceSwitch}
onCreateWorkspace={handleCreateWorkspace}
onToggleCollapse={handleToggleCollapse}
isCollapsed={isCollapsed}
onRenameWorkspace={handleRenameWorkspace}
onDeleteWorkspace={handleDeleteWorkspace}
/>
</div>
) : (
/* Full sidebar */
<>
<aside
ref={sidebarRef}
className='sidebar-container fixed inset-y-0 left-0 z-10 overflow-hidden dark:bg-[#1E1E1E]'
aria-label='Workspace sidebar'
onClick={handleSidebarClick}
>
<div className='flex h-full flex-col border-r pt-[14px] dark:border-[#2C2C2C]'>
{/* Header */}
<div className='flex-shrink-0 px-[14px]'>
<WorkspaceHeader
activeWorkspace={activeWorkspace}
workspaceId={workspaceId}
workspaces={workspaces}
isWorkspacesLoading={isWorkspacesLoading}
isCreatingWorkspace={isCreatingWorkspace}
isWorkspaceMenuOpen={isWorkspaceMenuOpen}
setIsWorkspaceMenuOpen={setIsWorkspaceMenuOpen}
onWorkspaceSwitch={handleWorkspaceSwitch}
onCreateWorkspace={handleCreateWorkspace}
onToggleCollapse={handleToggleCollapse}
isCollapsed={isCollapsed}
onRenameWorkspace={handleRenameWorkspace}
onDeleteWorkspace={handleDeleteWorkspace}
/>
</div>
{/* Search */}
<div className='mx-[8px] mt-[14px] flex flex-shrink-0 cursor-pointer items-center justify-between rounded-[8px] bg-[#272727] px-[6px] py-[7px] dark:bg-[#272727]'>
<div className='flex items-center gap-[6px]'>
<Search className='h-[16px] w-[16px] text-[#7D7D7D] dark:text-[#7D7D7D]' />
<p className='translate-y-[0.25px] font-medium text-[#B1B1B1] text-small dark:text-[#B1B1B1]'>
Search
</p>
</div>
<p className='font-medium text-[#7D7D7D] text-small dark:text-[#7D7D7D]'>K</p>
</div>
{/* Workflows */}
<div className='workflows-section relative mt-[14px] flex flex-1 flex-col overflow-hidden'>
{/* Header - Always visible */}
<div className='flex flex-shrink-0 flex-col space-y-[4px] px-[14px]'>
<div className='flex items-center justify-between'>
<div className='font-medium text-[#AEAEAE] text-small dark:text-[#AEAEAE]'>
Workflows
{/* Search */}
<div
className='mx-[8px] mt-[12px] flex flex-shrink-0 cursor-pointer items-center justify-between rounded-[8px] bg-[#272727] px-[8px] py-[7px] dark:bg-[#272727]'
onClick={() => setIsSearchModalOpen(true)}
>
<div className='flex items-center gap-[6px]'>
<Search className='h-[14px] w-[14px] text-[#7D7D7D] dark:text-[#7D7D7D]' />
<p className='translate-y-[0.25px] font-medium text-[#B1B1B1] text-small dark:text-[#B1B1B1]'>
Search
</p>
</div>
<div className='flex items-center justify-center gap-[10px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='translate-y-[-0.25px] p-[1px]'
onClick={handleImportWorkflow}
disabled={isImporting}
>
<ArrowDown className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content className='py-[2.5px]'>
<p>{isImporting ? 'Importing workflow...' : 'Import from JSON'}</p>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='mr-[1px] translate-y-[-0.25px] p-[1px]'
onClick={handleCreateFolder}
disabled={isCreatingFolder}
>
<FolderPlus className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content className='py-[2.5px]'>
<p>{isCreatingFolder ? 'Creating folder...' : 'Create folder'}</p>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='outline'
className='translate-y-[-0.25px] p-[1px]'
onClick={handleCreateWorkflow}
disabled={isCreatingWorkflow}
>
<Plus className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content className='py-[2.5px]'>
<p>{isCreatingWorkflow ? 'Creating workflow...' : 'Create workflow'}</p>
</Tooltip.Content>
</Tooltip.Root>
<p className='font-medium text-[#7D7D7D] text-small dark:text-[#7D7D7D]'>K</p>
</div>
{/* Workflows */}
<div className='workflows-section relative mt-[14px] flex flex-1 flex-col overflow-hidden'>
{/* Header - Always visible */}
<div className='flex flex-shrink-0 flex-col space-y-[4px] px-[14px]'>
<div className='flex items-center justify-between'>
<div className='font-medium text-[#AEAEAE] text-small dark:text-[#AEAEAE]'>
Workflows
</div>
<div className='flex items-center justify-center gap-[10px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='translate-y-[-0.25px] p-[1px]'
onClick={handleImportWorkflow}
disabled={isImporting}
>
<ArrowDown className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content className='py-[2.5px]'>
<p>{isImporting ? 'Importing workflow...' : 'Import from JSON'}</p>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='mr-[1px] translate-y-[-0.25px] p-[1px]'
onClick={handleCreateFolder}
disabled={isCreatingFolder}
>
<FolderPlus className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content className='py-[2.5px]'>
<p>{isCreatingFolder ? 'Creating folder...' : 'Create folder'}</p>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='outline'
className='translate-y-[-0.25px] p-[1px]'
onClick={handleCreateWorkflow}
disabled={isCreatingWorkflow}
>
<Plus className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content className='py-[2.5px]'>
<p>{isCreatingWorkflow ? 'Creating workflow...' : 'Create workflow'}</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
</div>
{/* Scrollable workflow list */}
<div
ref={scrollContainerRef}
className='mt-[6px] flex-1 overflow-y-auto overflow-x-hidden px-[8px]'
>
<WorkflowList
regularWorkflows={regularWorkflows}
isLoading={isLoading}
isImporting={isImporting}
setIsImporting={setIsImporting}
fileInputRef={fileInputRef}
scrollContainerRef={scrollContainerRef}
/>
</div>
</div>
{/* Footer Navigation */}
<FooterNavigation />
</div>
</aside>
{/* Scrollable workflow list */}
<div
ref={scrollContainerRef}
className='mt-[6px] flex-1 overflow-y-auto overflow-x-hidden px-[8px]'
>
<WorkflowList
regularWorkflows={regularWorkflows}
isLoading={isLoading}
isImporting={isImporting}
setIsImporting={setIsImporting}
fileInputRef={fileInputRef}
scrollContainerRef={scrollContainerRef}
/>
</div>
</div>
{/* Resize Handle */}
<div
className='fixed top-0 bottom-0 left-[calc(var(--sidebar-width)-4px)] z-20 w-[8px] cursor-ew-resize'
onMouseDown={handleMouseDown}
role='separator'
aria-orientation='vertical'
aria-label='Resize sidebar'
/>
</>
)}
{/* Footer Navigation */}
<FooterNavigation />
</div>
</aside>
{/* Resize Handle */}
<div
className='fixed top-0 bottom-0 left-[calc(var(--sidebar-width)-4px)] z-20 w-[8px] cursor-ew-resize'
onMouseDown={handleMouseDown}
role='separator'
aria-orientation='vertical'
aria-label='Resize sidebar'
{/* Universal Search Modal */}
<SearchModal
open={isSearchModalOpen}
onOpenChange={setIsSearchModalOpen}
workflows={searchModalWorkflows}
workspaces={searchModalWorkspaces}
isOnWorkflowPage={!!workflowId}
/>
</>
)

View File

@@ -9,13 +9,11 @@ import { getEnv, isTruthy } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { generateWorkspaceName } from '@/lib/naming'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { SearchModal } from '@/app/workspace/[workspaceId]/w/components/search-modal/search-modal'
import {
CreateMenu,
FloatingNavigation,
FolderTree,
HelpModal,
KeyboardShortcut,
KnowledgeBaseTags,
KnowledgeTags,
LogsFilters,
@@ -25,12 +23,8 @@ import {
WorkspaceHeader,
WorkspaceSelector,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-selector/components/invite-modal/invite-modal'
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/workspace-header/components/invite-modal/invite-modal'
import { useAutoScroll } from '@/app/workspace/[workspaceId]/w/hooks/use-auto-scroll'
import {
getKeyboardShortcutText,
useGlobalShortcuts,
} from '@/app/workspace/[workspaceId]/w/hooks/use-keyboard-shortcuts'
import { useKnowledgeBasesList } from '@/hooks/use-knowledge'
import { useSubscriptionStore } from '@/stores/subscription/store'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
@@ -85,7 +79,7 @@ interface TemplateData {
}
export function Sidebar() {
useGlobalShortcuts()
// useGlobalShortcuts()
const {
workflows,
@@ -914,7 +908,7 @@ export function Sidebar() {
<span className='flex h-8 flex-1 items-center px-0 text-muted-foreground text-sm leading-none'>
Search anything
</span>
<KeyboardShortcut shortcut={getKeyboardShortcutText('K', true)} />
{/* <KeyboardShortcut shortcut={getKeyboardShortcutText('K', true)} /> */}
</button>
</div>
@@ -1027,15 +1021,6 @@ export function Sidebar() {
<HelpModal open={showHelp} onOpenChange={setShowHelp} />
<InviteModal open={showInviteMembers} onOpenChange={setShowInviteMembers} />
<SubscriptionModal open={showSubscriptionModal} onOpenChange={setShowSubscriptionModal} />
<SearchModal
open={showSearchModal}
onOpenChange={setShowSearchModal}
workflows={searchWorkflows}
workspaces={searchWorkspaces}
knowledgeBases={searchKnowledgeBases}
isOnWorkflowPage={isOnWorkflowPage}
/>
</>
)
}

View File

@@ -3,4 +3,3 @@ export { useDeleteFolder } from './use-delete-folder'
export { useDeleteWorkflow } from './use-delete-workflow'
export { useDuplicateFolder } from './use-duplicate-folder'
export { useDuplicateWorkflow } from './use-duplicate-workflow'
export { useKeyboardShortcuts } from './use-keyboard-shortcuts'

View File

@@ -1,114 +0,0 @@
'use client'
import { useEffect, useMemo } from 'react'
import { useRouter } from 'next/navigation'
/**
* Detect if the current platform is Mac
*/
export function isMacPlatform() {
if (typeof navigator === 'undefined') return false
return navigator.platform.toUpperCase().indexOf('MAC') >= 0
}
/**
* Get a formatted keyboard shortcut string for display
* @param key The key part of the shortcut (e.g., "Enter")
* @param requiresCmd Whether the shortcut requires Cmd/Ctrl
* @param requiresShift Whether the shortcut requires Shift
* @param requiresAlt Whether the shortcut requires Alt/Option
*/
export function getKeyboardShortcutText(
key: string,
requiresCmd = false,
requiresShift = false,
requiresAlt = false
) {
const isMac = isMacPlatform()
const cmdKey = isMac ? '⌘' : 'Ctrl'
const altKey = isMac ? '⌥' : 'Alt'
const shiftKey = '⇧'
const parts: string[] = []
if (requiresCmd) parts.push(cmdKey)
if (requiresShift) parts.push(shiftKey)
if (requiresAlt) parts.push(altKey)
parts.push(key)
return parts.join('+')
}
/**
* Hook to manage keyboard shortcuts
* @param onRunWorkflow - Function to run when Cmd/Ctrl+Enter is pressed
* @param isDisabled - Whether shortcuts should be disabled
*/
export function useKeyboardShortcuts(onRunWorkflow: () => void, isDisabled = false) {
// Memoize the platform detection
const isMac = useMemo(() => isMacPlatform(), [])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Run workflow with Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux)
if (event.key === 'Enter' && ((isMac && event.metaKey) || (!isMac && event.ctrlKey))) {
// Don't trigger if user is typing in an input, textarea, or contenteditable element
const activeElement = document.activeElement
const isEditableElement =
activeElement instanceof HTMLInputElement ||
activeElement instanceof HTMLTextAreaElement ||
activeElement?.hasAttribute('contenteditable')
if (!isEditableElement && !isDisabled) {
event.preventDefault()
onRunWorkflow()
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onRunWorkflow, isDisabled, isMac])
}
/**
* Hook to manage global navigation shortcuts
*/
export function useGlobalShortcuts() {
const router = useRouter()
const isMac = useMemo(() => isMacPlatform(), [])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Don't trigger if user is typing in an input, textarea, or contenteditable element
const activeElement = document.activeElement
const isEditableElement =
activeElement instanceof HTMLInputElement ||
activeElement instanceof HTMLTextAreaElement ||
activeElement?.hasAttribute('contenteditable')
if (isEditableElement) return
// Cmd/Ctrl + Shift + L - Navigate to Logs
if (
event.key.toLowerCase() === 'l' &&
event.shiftKey &&
((isMac && event.metaKey) || (!isMac && event.ctrlKey))
) {
event.preventDefault()
const pathParts = window.location.pathname.split('/')
const workspaceIndex = pathParts.indexOf('workspace')
if (workspaceIndex !== -1 && pathParts[workspaceIndex + 1]) {
const workspaceId = pathParts[workspaceIndex + 1]
router.push(`/workspace/${workspaceId}/logs`)
} else {
router.push('/workspace')
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [router, isMac])
}

View File

@@ -0,0 +1,54 @@
/**
* User color palette matching terminal.tsx RUN_ID_COLORS
* These colors are used consistently across cursors, avatars, and terminal run IDs
*/
export const USER_COLORS = [
'#4ADE80', // Green
'#F472B6', // Pink
'#60C5FF', // Blue
'#FF8533', // Orange
'#C084FC', // Purple
'#FCD34D', // Yellow
] as const
/**
* Hash a user ID to generate a consistent numeric index
*
* @param userId - The user ID to hash
* @returns A positive integer
*/
function hashUserId(userId: string): number {
return Math.abs(Array.from(userId).reduce((acc, char) => acc + char.charCodeAt(0), 0))
}
/**
* Gets a consistent color for a user based on their ID.
* The same user will always get the same color across cursors, avatars, and terminal.
*
* @param userId - The unique user identifier
* @returns A hex color string
*/
export function getUserColor(userId: string): string {
const hash = hashUserId(userId)
return USER_COLORS[hash % USER_COLORS.length]
}
/**
* Creates a stable mapping of user IDs to color indices for a list of users.
* Useful when you need to maintain consistent color assignments across renders.
*
* @param userIds - Array of user IDs to map
* @returns Map of user ID to color index
*/
export function createUserColorMap(userIds: string[]): Map<string, number> {
const colorMap = new Map<string, number>()
let colorIndex = 0
for (const userId of userIds) {
if (!colorMap.has(userId)) {
colorMap.set(userId, colorIndex++)
}
}
return colorMap
}

View File

@@ -59,7 +59,7 @@ import { cn } from '@/lib/utils'
* Ensures consistent height and styling across items, folders, and back button.
*/
const POPOVER_ITEM_BASE_CLASSES =
'flex h-[25px] cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] font-base text-[#E6E6E6] text-[12px] transition-colors dark:text-[#E6E6E6] [&_svg]:transition-colors disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed'
'flex h-[25px] min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] font-base text-[#E6E6E6] text-[12px] transition-colors dark:text-[#E6E6E6] [&_svg]:transition-colors disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed'
/**
* Variant-specific active state styles for popover items.
@@ -139,7 +139,7 @@ const Popover: React.FC<PopoverProps> = ({ children, variant = 'default', ...pro
(id: string, title: string, onLoad?: () => void | Promise<void>, onSelect?: () => void) => {
setCurrentFolder(id)
setFolderTitle(title)
setOnFolderSelect(onSelect ?? null)
setOnFolderSelect(() => onSelect ?? null)
if (onLoad) {
void Promise.resolve(onLoad())
}

View File

@@ -95,7 +95,7 @@ export function TagInput({
onBlur={handleBlur}
placeholder={value.length === 0 ? placeholder : ''}
disabled={disabled}
className='h-7 min-w-[120px] flex-1 border-0 p-0 px-1 text-sm shadow-none placeholder:text-muted-foreground/60 focus-visible:ring-0 focus-visible:ring-offset-0'
className='h-7 min-w-[120px] flex-1 border-0 bg-transparent p-0 px-1 text-sm shadow-none placeholder:text-muted-foreground/60 focus-visible:ring-0 focus-visible:ring-offset-0'
/>
)}
</div>

View File

@@ -882,6 +882,12 @@ export function useCollaborativeWorkflow() {
}
findAllDescendants(id)
// If the currently edited block is among the blocks being removed, clear selection to restore the last tab
const currentEditedBlockId = usePanelEditorStore.getState().currentBlockId
if (currentEditedBlockId && blocksToRemove.has(currentEditedBlockId)) {
usePanelEditorStore.getState().clearCurrentBlock()
}
// Capture state before removal, including all nested blocks with subblock values
const allBlocks = mergeSubblockState(workflowStore.blocks, activeWorkflowId || undefined)
const capturedBlocks: Record<string, BlockState> = {}
@@ -1262,6 +1268,9 @@ export function useCollaborativeWorkflow() {
}
)
// Focus the newly duplicated block in the editor
usePanelEditorStore.getState().setCurrentBlockId(newId)
executeQueuedOperation('duplicate', 'block', duplicatedBlockData, () => {
workflowStore.addBlock(
newId,

View File

@@ -7,9 +7,9 @@ import { usePanelStore } from '../store'
/**
* Connections height constraints
*/
const DEFAULT_CONNECTIONS_HEIGHT = 200
const DEFAULT_CONNECTIONS_HEIGHT = 115
const MIN_CONNECTIONS_HEIGHT = 30
const MAX_CONNECTIONS_HEIGHT = DEFAULT_CONNECTIONS_HEIGHT
const MAX_CONNECTIONS_HEIGHT = 200
/**
* State for the Editor panel.

View File

@@ -7,8 +7,10 @@ import { persist } from 'zustand/middleware'
interface SidebarState {
workspaceDropdownOpen: boolean
sidebarWidth: number
isCollapsed: boolean
setWorkspaceDropdownOpen: (isOpen: boolean) => void
setSidebarWidth: (width: number) => void
setIsCollapsed: (isCollapsed: boolean) => void
}
/**
@@ -23,6 +25,7 @@ export const useSidebarStore = create<SidebarState>()(
(set, get) => ({
workspaceDropdownOpen: false,
sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
isCollapsed: false,
setWorkspaceDropdownOpen: (isOpen) => set({ workspaceDropdownOpen: isOpen }),
setSidebarWidth: (width) => {
// Only enforce minimum - maximum is enforced dynamically by the resize hook
@@ -33,14 +36,26 @@ export const useSidebarStore = create<SidebarState>()(
document.documentElement.style.setProperty('--sidebar-width', `${clampedWidth}px`)
}
},
setIsCollapsed: (isCollapsed) => {
set({ isCollapsed })
// Set width to 0 when collapsed (floating UI doesn't need sidebar space)
if (isCollapsed && typeof window !== 'undefined') {
document.documentElement.style.setProperty('--sidebar-width', '0px')
} else if (!isCollapsed && typeof window !== 'undefined') {
// Restore to stored width when expanding
const currentWidth = get().sidebarWidth
document.documentElement.style.setProperty('--sidebar-width', `${currentWidth}px`)
}
},
}),
{
name: 'sidebar-state',
onRehydrateStorage: () => (state) => {
// Validate and enforce constraints after rehydration
if (state && typeof window !== 'undefined') {
// Sync CSS variables with validated state
document.documentElement.style.setProperty('--sidebar-width', `${state.sidebarWidth}px`)
// Use 0 width if collapsed (floating UI), otherwise use stored width
const width = state.isCollapsed ? 0 : state.sidebarWidth
document.documentElement.style.setProperty('--sidebar-width', `${width}px`)
}
},
}

View File

@@ -1,3 +1,3 @@
export type { ConsoleEntry, ConsoleStore, ConsoleUpdate } from './console'
export { useTerminalConsoleStore } from './console'
export { useTerminalStore } from './store'
export { DEFAULT_TERMINAL_HEIGHT, useTerminalStore } from './store'

View File

@@ -4,7 +4,7 @@ import { persist } from 'zustand/middleware'
/**
* Display mode type for terminal output
*/
export type DisplayMode = 'raw' | 'prettier'
// export type DisplayMode = 'raw' | 'prettier'
/**
* Terminal state interface
@@ -14,8 +14,8 @@ interface TerminalState {
setTerminalHeight: (height: number) => void
outputPanelWidth: number
setOutputPanelWidth: (width: number) => void
displayMode: DisplayMode
setDisplayMode: (mode: DisplayMode) => void
// displayMode: DisplayMode
// setDisplayMode: (mode: DisplayMode) => void
_hasHydrated: boolean
setHasHydrated: (hasHydrated: boolean) => void
}
@@ -25,7 +25,7 @@ interface TerminalState {
* Note: Maximum height is enforced dynamically at 70% of viewport height in the resize hook
*/
const MIN_TERMINAL_HEIGHT = 30
const DEFAULT_TERMINAL_HEIGHT = 100
export const DEFAULT_TERMINAL_HEIGHT = 145
/**
* Output panel width constraints
@@ -36,7 +36,7 @@ const DEFAULT_OUTPUT_PANEL_WIDTH = 400
/**
* Default display mode
*/
const DEFAULT_DISPLAY_MODE: DisplayMode = 'prettier'
// const DEFAULT_DISPLAY_MODE: DisplayMode = 'prettier'
export const useTerminalStore = create<TerminalState>()(
persist(
@@ -56,10 +56,10 @@ export const useTerminalStore = create<TerminalState>()(
const clampedWidth = Math.max(MIN_OUTPUT_PANEL_WIDTH, width)
set({ outputPanelWidth: clampedWidth })
},
displayMode: DEFAULT_DISPLAY_MODE,
setDisplayMode: (mode) => {
set({ displayMode: mode })
},
// displayMode: DEFAULT_DISPLAY_MODE,
// setDisplayMode: (mode) => {
// set({ displayMode: mode })
// },
_hasHydrated: false,
setHasHydrated: (hasHydrated) => {
set({ _hasHydrated: hasHydrated })

View File

@@ -0,0 +1,431 @@
import { v4 as uuidv4 } from 'uuid'
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { createLogger } from '@/lib/logs/console/logger'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type {
Variable,
VariablesDimensions,
VariablesPosition,
VariablesStore,
VariableType,
} from './types'
const logger = createLogger('VariablesModalStore')
/**
* Floating variables modal default dimensions.
* Matches the chat modal baseline for visual consistency.
*/
const DEFAULT_WIDTH = 250
const DEFAULT_HEIGHT = 286
/**
* Minimum and maximum modal dimensions.
* Kept in sync with the chat modal experience.
*/
export const MIN_VARIABLES_WIDTH = DEFAULT_WIDTH
export const MIN_VARIABLES_HEIGHT = DEFAULT_HEIGHT
export const MAX_VARIABLES_WIDTH = 500
export const MAX_VARIABLES_HEIGHT = 600
/**
* Compute a center-biased default position, factoring in current layout chrome
* (sidebar, right panel, and terminal), mirroring the chat modal behavior.
*/
const calculateDefaultPosition = (): VariablesPosition => {
if (typeof window === 'undefined') {
return { x: 100, y: 100 }
}
const sidebarWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
)
const panelWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
)
const terminalHeight = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
)
const availableWidth = window.innerWidth - sidebarWidth - panelWidth
const availableHeight = window.innerHeight - terminalHeight
const x = sidebarWidth + (availableWidth - DEFAULT_WIDTH) / 2
const y = (availableHeight - DEFAULT_HEIGHT) / 2
return { x, y }
}
/**
* Constrain a position to the visible canvas, considering layout chrome.
*/
const constrainPosition = (
position: VariablesPosition,
width: number = DEFAULT_WIDTH,
height: number = DEFAULT_HEIGHT
): VariablesPosition => {
if (typeof window === 'undefined') return position
const sidebarWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
)
const panelWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
)
const terminalHeight = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
)
const minX = sidebarWidth
const maxX = window.innerWidth - panelWidth - width
const minY = 0
const maxY = window.innerHeight - terminalHeight - height
return {
x: Math.max(minX, Math.min(maxX, position.x)),
y: Math.max(minY, Math.min(maxY, position.y)),
}
}
/**
* Return a valid, constrained position. If the stored one is off-bounds due to
* layout changes, prefer a fresh default center position.
*/
export const getVariablesPosition = (
stored: VariablesPosition | null,
width: number = DEFAULT_WIDTH,
height: number = DEFAULT_HEIGHT
): VariablesPosition => {
if (!stored) return calculateDefaultPosition()
const constrained = constrainPosition(stored, width, height)
const deltaX = Math.abs(constrained.x - stored.x)
const deltaY = Math.abs(constrained.y - stored.y)
if (deltaX > 100 || deltaY > 100) return calculateDefaultPosition()
return constrained
}
/**
* Validate a variable's value given its type. Returns an error message or undefined.
*/
function validateVariable(variable: Variable): string | undefined {
try {
switch (variable.type) {
case 'number': {
return Number.isNaN(Number(variable.value)) ? 'Not a valid number' : undefined
}
case 'boolean': {
return !/^(true|false)$/i.test(String(variable.value).trim())
? 'Expected "true" or "false"'
: undefined
}
case 'object': {
try {
const valueToEvaluate = String(variable.value).trim()
if (!valueToEvaluate.startsWith('{') || !valueToEvaluate.endsWith('}')) {
return 'Not a valid object format'
}
// eslint-disable-next-line no-new-func
const parsed = new Function(`return ${valueToEvaluate}`)()
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
return 'Not a valid object'
}
return undefined
} catch (e) {
logger.error('Object parsing error:', e)
return 'Invalid object syntax'
}
}
case 'array': {
try {
const parsed = JSON.parse(String(variable.value))
if (!Array.isArray(parsed)) {
return 'Not a valid JSON array'
}
} catch {
return 'Invalid JSON array syntax'
}
return undefined
}
default:
return undefined
}
} catch (e) {
return e instanceof Error ? e.message : 'Invalid format'
}
}
/**
* Migrate deprecated type 'string' -> 'plain'.
*/
function migrateStringToPlain(variable: Variable): Variable {
if (variable.type !== 'string') return variable
return { ...variable, type: 'plain' as const }
}
/**
* Floating Variables modal + Variables data store.
*/
export const useVariablesStore = create<VariablesStore>()(
devtools(
persist(
(set, get) => ({
// UI
isOpen: false,
position: null,
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
setIsOpen: (open) => set({ isOpen: open }),
setPosition: (position) => set({ position }),
setDimensions: (dimensions) =>
set({
width: Math.max(MIN_VARIABLES_WIDTH, Math.min(MAX_VARIABLES_WIDTH, dimensions.width)),
height: Math.max(
MIN_VARIABLES_HEIGHT,
Math.min(MAX_VARIABLES_HEIGHT, dimensions.height)
),
}),
resetPosition: () => set({ position: null }),
// Data
variables: {},
isLoading: false,
error: null,
async loadForWorkflow(workflowId) {
try {
set({ isLoading: true, error: null })
const res = await fetch(`/api/workflows/${workflowId}/variables`, { method: 'GET' })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `Failed to load variables: ${res.statusText}`)
}
const data = await res.json()
const variables = (data?.data as Record<string, Variable>) || {}
// Migrate any deprecated types and merge into store (remove other workflow entries)
const migrated: Record<string, Variable> = Object.fromEntries(
Object.entries(variables).map(([id, v]) => [id, migrateStringToPlain(v)])
)
set((state) => {
const withoutThisWorkflow = Object.fromEntries(
Object.entries(state.variables).filter(
(entry): entry is [string, Variable] => entry[1].workflowId !== workflowId
)
)
return {
variables: { ...withoutThisWorkflow, ...migrated },
isLoading: false,
error: null,
}
})
} catch (e) {
const message = e instanceof Error ? e.message : 'Unknown error'
set({ isLoading: false, error: message })
}
},
addVariable: (variable, providedId) => {
const id = providedId || uuidv4()
const state = get()
const workflowVariables = state
.getVariablesByWorkflowId(variable.workflowId)
.map((v) => ({ id: v.id, name: v.name }))
// Default naming: variableN
if (!variable.name || /^variable\d+$/.test(variable.name)) {
const existingNumbers = workflowVariables
.map((v) => {
const match = v.name.match(/^variable(\d+)$/)
return match ? Number.parseInt(match[1]) : 0
})
.filter((n) => !Number.isNaN(n))
const nextNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1
variable.name = `variable${nextNumber}`
}
// Ensure uniqueness
let uniqueName = variable.name
let nameIndex = 1
while (workflowVariables.some((v) => v.name === uniqueName)) {
uniqueName = `${variable.name} (${nameIndex})`
nameIndex++
}
if (variable.type === 'string') {
variable.type = 'plain'
}
const newVariable: Variable = {
id,
workflowId: variable.workflowId,
name: uniqueName,
type: variable.type,
value: variable.value ?? '',
validationError: undefined,
}
const validationError = validateVariable(newVariable)
if (validationError) {
newVariable.validationError = validationError
}
set((state) => ({
variables: {
...state.variables,
[id]: newVariable,
},
}))
return id
},
updateVariable: (id, update) => {
set((state) => {
const existing = state.variables[id]
if (!existing) return state
// Handle name changes: keep references in sync across workflow values
if (update.name !== undefined) {
const oldVariableName = existing.name
const newName = String(update.name).trim()
if (!newName) {
update = { ...update, name: undefined }
} else if (newName !== oldVariableName) {
const subBlockStore = useSubBlockStore.getState()
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (activeWorkflowId) {
const workflowValues = subBlockStore.workflowValues[activeWorkflowId] || {}
const updatedWorkflowValues = { ...workflowValues }
Object.entries(workflowValues).forEach(([blockId, blockValues]) => {
Object.entries(blockValues as Record<string, any>).forEach(
([subBlockId, value]) => {
const oldVarName = oldVariableName.replace(/\s+/g, '').toLowerCase()
const newVarName = newName.replace(/\s+/g, '').toLowerCase()
const regex = new RegExp(`<variable\\.${oldVarName}>`, 'gi')
updatedWorkflowValues[blockId][subBlockId] = updateReferences(
value,
regex,
`<variable.${newVarName}>`
)
function updateReferences(
val: any,
refRegex: RegExp,
replacement: string
): any {
if (typeof val === 'string') {
return refRegex.test(val) ? val.replace(refRegex, replacement) : val
}
if (Array.isArray(val)) {
return val.map((item) => updateReferences(item, refRegex, replacement))
}
if (val !== null && typeof val === 'object') {
const result: Record<string, any> = { ...val }
for (const key in result) {
result[key] = updateReferences(result[key], refRegex, replacement)
}
return result
}
return val
}
}
)
})
useSubBlockStore.setState({
workflowValues: {
...subBlockStore.workflowValues,
[activeWorkflowId]: updatedWorkflowValues,
},
})
}
}
}
// Handle deprecated -> new type migration
if (update.type === 'string') {
update = { ...update, type: 'plain' as VariableType }
}
const updated: Variable = {
...existing,
...update,
validationError: undefined,
}
// Validate only when type or value changed
if (update.type || update.value !== undefined) {
updated.validationError = validateVariable(updated)
}
return {
variables: {
...state.variables,
[id]: updated,
},
}
})
},
deleteVariable: (id) => {
set((state) => {
if (!state.variables[id]) return state
const { [id]: _deleted, ...rest } = state.variables
return { variables: rest }
})
},
duplicateVariable: (id, providedId) => {
const state = get()
const existing = state.variables[id]
if (!existing) return ''
const newId = providedId || uuidv4()
const workflowVariables = state.getVariablesByWorkflowId(existing.workflowId)
const baseName = `${existing.name} (copy)`
let uniqueName = baseName
let nameIndex = 1
while (workflowVariables.some((v) => v.name === uniqueName)) {
uniqueName = `${baseName} (${nameIndex})`
nameIndex++
}
set((state) => ({
variables: {
...state.variables,
[newId]: {
id: newId,
workflowId: existing.workflowId,
name: uniqueName,
type: existing.type,
value: existing.value,
},
},
}))
return newId
},
getVariablesByWorkflowId: (workflowId) => {
return Object.values(get().variables).filter((v) => v.workflowId === workflowId)
},
}),
{
name: 'variables-modal-store',
}
)
)
)
/**
* Get default floating variables modal dimensions.
*/
export const getDefaultVariablesDimensions = (): VariablesDimensions => ({
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
})

View File

@@ -0,0 +1,62 @@
/**
* Variable types supported by the variables modal/editor.
* Note: 'string' is deprecated. Use 'plain' for freeform text values instead.
*/
export type VariableType = 'plain' | 'number' | 'boolean' | 'object' | 'array' | 'string'
/**
* Workflow-scoped variable model.
*/
export interface Variable {
id: string
workflowId: string
name: string
type: VariableType
value: unknown
validationError?: string
}
/**
* 2D position used by the floating variables modal.
*/
export interface VariablesPosition {
x: number
y: number
}
/**
* Dimensions for the floating variables modal.
*/
export interface VariablesDimensions {
width: number
height: number
}
/**
* Public store interface for variables editor/modal.
* Combines UI state of the floating modal and the variables data/actions.
*/
export interface VariablesStore {
// UI State
isOpen: boolean
position: VariablesPosition | null
width: number
height: number
setIsOpen: (open: boolean) => void
setPosition: (position: VariablesPosition) => void
setDimensions: (dimensions: VariablesDimensions) => void
resetPosition: () => void
// Data
variables: Record<string, Variable>
isLoading: boolean
error: string | null
// Actions
loadForWorkflow: (workflowId: string) => Promise<void>
addVariable: (variable: Omit<Variable, 'id'>, providedId?: string) => string
updateVariable: (id: string, update: Partial<Omit<Variable, 'id' | 'workflowId'>>) => void
deleteVariable: (id: string) => void
duplicateVariable: (id: string, providedId?: string) => string
getVariablesByWorkflowId: (workflowId: string) => Variable[]
}

View File

@@ -94,62 +94,6 @@ export default {
padding: 'padding',
},
keyframes: {
'slide-down': {
'0%': {
transform: 'translate(-50%, -100%)',
opacity: '0',
},
'100%': {
transform: 'translate(-50%, 0)',
opacity: '1',
},
},
'notification-slide': {
'0%': {
opacity: '0',
transform: 'translateY(-100%)',
},
'100%': {
opacity: '1',
transform: 'translateY(0)',
},
},
'notification-fade-out': {
'0%': {
opacity: '1',
transform: 'translateY(0)',
},
'100%': {
opacity: '0',
transform: 'translateY(0)',
},
},
'fade-up': {
'0%': {
opacity: '0',
transform: 'translateY(10px)',
},
'100%': {
opacity: '1',
transform: 'translateY(0)',
},
},
'rocket-pulse': {
'0%, 100%': {
opacity: '1',
},
'50%': {
opacity: '0.7',
},
},
'run-glow': {
'0%, 100%': {
filter: 'opacity(1)',
},
'50%': {
filter: 'opacity(0.7)',
},
},
'caret-blink': {
'0%,70%,100%': {
opacity: '1',
@@ -158,30 +102,6 @@ export default {
opacity: '0',
},
},
'pulse-slow': {
'0%, 100%': {
opacity: '1',
},
'50%': {
opacity: '0.7',
},
},
'accordion-down': {
from: {
height: '0',
},
to: {
height: 'var(--radix-accordion-content-height)',
},
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)',
},
to: {
height: '0',
},
},
'slide-left': {
'0%': {
transform: 'translateX(0)',
@@ -225,40 +145,6 @@ export default {
transform: 'translateX(100%)',
},
},
orbit: {
'0%': {
transform:
'rotate(calc(var(--angle) * 1deg)) translateY(calc(var(--radius) * 1px)) rotate(calc(var(--angle) * -1deg))',
},
'100%': {
transform:
'rotate(calc(var(--angle) * 1deg + 360deg)) translateY(calc(var(--radius) * 1px)) rotate(calc((var(--angle) * -1deg) - 360deg))',
},
},
marquee: {
from: {
transform: 'translateX(0)',
},
to: {
transform: 'translateX(calc(-100% - var(--gap)))',
},
},
'marquee-vertical': {
from: {
transform: 'translateY(0)',
},
to: {
transform: 'translateY(calc(-100% - var(--gap)))',
},
},
'fade-in': {
from: {
opacity: '0',
},
to: {
opacity: '1',
},
},
'placeholder-pulse': {
'0%, 100%': {
opacity: '0.5',
@@ -269,25 +155,12 @@ export default {
},
},
animation: {
'slide-down': 'slide-down 0.3s ease-out',
'notification-slide': 'notification-slide 0.3s ease-out forwards',
'notification-fade-out': 'notification-fade-out 0.2s ease-out forwards',
'fade-up': 'fade-up 0.5s ease-out forwards',
'rocket-pulse': 'rocket-pulse 1.5s ease-in-out infinite',
'run-glow': 'run-glow 2s ease-in-out infinite',
'caret-blink': 'caret-blink 1.25s ease-out infinite',
'pulse-slow': 'pulse-slow 3s ease-in-out infinite',
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
'slide-left': 'slide-left 80s linear infinite',
'slide-right': 'slide-right 80s linear infinite',
'dash-animation': 'dash-animation 1.5s linear infinite',
'pulse-ring': 'pulse-ring 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'code-shimmer': 'code-shimmer 1.5s infinite',
orbit: 'orbit calc(var(--duration, 2) * 1s) linear infinite',
marquee: 'marquee var(--duration) infinite linear',
'marquee-vertical': 'marquee-vertical var(--duration) linear infinite',
'fade-in': 'fade-in 0.3s ease-in-out forwards',
'placeholder-pulse': 'placeholder-pulse 1.5s ease-in-out infinite',
},
},