fix(variables): fixed floating variables, added autcomplete off to prevent suggested values (#1908)

* fixed variables

* add autocomplete=off to prevent suggestions for subblock values
This commit is contained in:
Waleed
2025-11-11 17:52:46 -08:00
committed by GitHub
parent 769555676e
commit 96f134b127
19 changed files with 328 additions and 251 deletions
@@ -1,7 +1,8 @@
'use client'
import { useState } from 'react'
import { Loader2, Trash2 } from 'lucide-react'
import { Loader2 } from 'lucide-react'
import { Trash } from '@/components/emcn/icons/trash'
import {
AlertDialog,
AlertDialogAction,
@@ -97,7 +98,7 @@ export function DeleteChunkModal({
</>
) : (
<>
<Trash2 className='mr-2 h-4 w-4' />
<Trash className='mr-2 h-4 w-4' />
Delete
</>
)}
@@ -1,9 +1,10 @@
'use client'
import { Suspense, startTransition, useCallback, useEffect, useState } from 'react'
import { ChevronLeft, ChevronRight, Circle, CircleOff, FileText, Plus, Trash2 } from 'lucide-react'
import { ChevronLeft, ChevronRight, Circle, CircleOff, FileText, Plus } from 'lucide-react'
import { useParams, useSearchParams } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { Button, Checkbox, SearchHighlight } from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import {
@@ -369,7 +370,7 @@ export function Document({
disabled={!userPermissions.canEdit}
className='h-8 w-8 p-0 text-gray-500 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50'
>
<Trash2 className='h-4 w-4' />
<Trash className='h-4 w-4' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Delete Chunk</Tooltip.Content>
@@ -14,10 +14,10 @@ import {
Loader2,
Plus,
RotateCcw,
Trash2,
} from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import {
AlertDialog,
AlertDialogAction,
@@ -1070,7 +1070,7 @@ export function KnowledgeBase({
}
className='h-8 w-8 p-0 text-gray-500 hover:text-red-600 disabled:opacity-50'
>
<Trash2 className='h-4 w-4' />
<Trash className='h-4 w-4' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
@@ -1,7 +1,8 @@
'use client'
import { LibraryBig, MoreHorizontal, Trash2 } from 'lucide-react'
import { LibraryBig, MoreHorizontal } from 'lucide-react'
import Link from 'next/link'
import { Trash } from '@/components/emcn/icons/trash'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
@@ -107,7 +108,7 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
onClick={options.onDeleteKnowledgeBase}
className='flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 font-[380] text-red-600 text-sm hover:bg-secondary/50 focus:bg-secondary/50 focus:text-red-600'
>
<Trash2 className='h-4 w-4' />
<Trash className='h-4 w-4' />
Delete Knowledge Base
</DropdownMenuItem>
</div>
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { Check, Copy, Eye, EyeOff, Plus, RefreshCw, Trash2 } from 'lucide-react'
import { Check, Copy, Eye, EyeOff, Plus, RefreshCw } from 'lucide-react'
import { Trash } from '@/components/emcn/icons/trash'
import { Button, Card, CardContent, Input, Label } from '@/components/ui'
import { getEnv, isTruthy } from '@/lib/env'
import { cn, generatePassword } from '@/lib/utils'
@@ -266,7 +267,7 @@ export function AuthSelector({
disabled={disabled}
className='h-7 w-7 opacity-70'
>
<Trash2 className='h-4 w-4' />
<Trash className='h-4 w-4' />
</Button>
</div>
</li>
@@ -2,7 +2,7 @@
import type { MouseEvent as ReactMouseEvent } from 'react'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
import { History, Plus, Trash2 } from 'lucide-react'
import { History, Plus } from 'lucide-react'
import {
Button,
Popover,
@@ -12,6 +12,7 @@ import {
PopoverSection,
PopoverTrigger,
} from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { createLogger } from '@/lib/logs/console/logger'
import {
CopilotMessage,
@@ -387,7 +388,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
}}
aria-label='Delete chat'
>
<Trash2 className='h-[10px] w-[10px]' />
<Trash className='h-[10px] w-[10px]' />
</Button>
</div>
</PopoverItem>
@@ -1,7 +1,8 @@
'use client'
import { useMemo, useRef, useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { Plus } from 'lucide-react'
import { Trash } from '@/components/emcn/icons/trash'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { MAX_TAG_SLOTS } from '@/lib/knowledge/consts'
@@ -301,6 +302,7 @@ export function DocumentTagEntry({
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
autoComplete='off'
className={cn(
'w-full border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0',
isDuplicate && 'border-red-500 bg-red-50'
@@ -393,6 +395,7 @@ export function DocumentTagEntry({
value={cellValue}
readOnly
disabled={disabled || isReadOnly}
autoComplete='off'
className='w-full cursor-pointer border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
onClick={handleTypeDropdownClick}
onFocus={handleTypeFocus}
@@ -464,6 +467,7 @@ export function DocumentTagEntry({
onDrop={handlers.onDrop}
onDragOver={handlers.onDragOver}
disabled={disabled}
autoComplete='off'
className='w-full border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
@@ -507,7 +511,7 @@ export function DocumentTagEntry({
className='-translate-y-1/2 absolute top-1/2 right-2 h-8 w-8 opacity-0 group-hover:opacity-100'
onClick={() => handleDeleteRow(rowIndex)}
>
<Trash2 className='h-4 w-4 text-muted-foreground' />
<Trash className='h-4 w-4 text-muted-foreground' />
</Button>
</td>
) : null
@@ -1,9 +1,10 @@
import { useMemo, useRef } from 'react'
import { Plus, Trash } from 'lucide-react'
import { Plus } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import { Button } from '@/components/emcn/components/button/button'
import { Input } from '@/components/emcn/components/input/input'
import { Textarea } from '@/components/emcn/components/textarea/textarea'
import { Trash } from '@/components/emcn/icons/trash'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text'
@@ -1,7 +1,8 @@
'use client'
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { Plus } from 'lucide-react'
import { Trash } from '@/components/emcn/icons/trash'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -234,6 +235,7 @@ export function KnowledgeTagFilters({
value={cellValue}
readOnly
disabled={disabled || isLoading}
autoComplete='off'
className='w-full cursor-pointer border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
onClick={handleDropdownClick}
onFocus={handleFocus}
@@ -321,6 +323,7 @@ export function KnowledgeTagFilters({
}
}}
disabled={disabled}
autoComplete='off'
className='w-full border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
@@ -347,7 +350,7 @@ export function KnowledgeTagFilters({
className='-translate-y-1/2 absolute top-1/2 right-2 h-8 w-8 opacity-0 group-hover:opacity-100'
onClick={() => handleDeleteRow(rowIndex)}
>
<Trash2 className='h-4 w-4 text-muted-foreground' />
<Trash className='h-4 w-4 text-muted-foreground' />
</Button>
</td>
) : null
@@ -113,6 +113,7 @@ function McpInputWithTags({
onDragOver={handleDragOver}
placeholder={placeholder}
disabled={disabled}
autoComplete='off'
className={cn(!isPassword && 'text-transparent caret-foreground')}
/>
{!isPassword && (
@@ -1,5 +1,6 @@
import { useRef } from 'react'
import { Plus, Trash } from 'lucide-react'
import { Plus } from 'lucide-react'
import { Trash } from '@/components/emcn/icons/trash'
import 'prismjs/components/prism-json'
import Editor from 'react-simple-code-editor'
import { Badge, Button, Combobox, Input } from '@/components/emcn'
@@ -330,6 +331,7 @@ export function FieldFormat({
}
placeholder={valuePlaceholder}
disabled={isReadOnly}
autoComplete='off'
className={cn('allow-scroll w-full overflow-auto', inputClassName)}
style={{ overflowX: 'auto' }}
/>
@@ -378,6 +380,7 @@ export function FieldFormat({
onChange={(e) => updateField(field.id, 'name', e.target.value)}
placeholder={placeholder}
disabled={isReadOnly}
autoComplete='off'
/>
</div>
@@ -1,7 +1,7 @@
import { useMemo, useRef } from 'react'
import { Trash2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/emcn/components/button/button'
import { Trash } from '@/components/emcn/icons/trash'
import { Input } from '@/components/ui/input'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
@@ -202,6 +202,7 @@ export function Table({
onDrop={handlers.onDrop}
onDragOver={handlers.onDragOver}
disabled={isPreview || disabled}
autoComplete='off'
className='w-full border-0 bg-transparent px-[10px] py-[8px] text-transparent caret-white placeholder:text-[#787878] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
<div
@@ -262,7 +263,7 @@ export function Table({
className='-translate-y-1/2 absolute top-1/2 right-[8px] opacity-0 transition-opacity group-hover:opacity-100'
onClick={() => handleDeleteRow(rowIndex)}
>
<Trash2 className='h-[14px] w-[14px]' />
<Trash className='h-[14px] w-[14px]' />
</Button>
</td>
)
@@ -94,6 +94,7 @@ export function TimeInput({
disabled={isPreview || disabled}
value={value ? formatDisplayTime(value) : ''}
placeholder={placeholder || 'Select time'}
autoComplete='off'
className={cn('cursor-pointer', !value && 'text-muted-foreground', className)}
/>
</div>
@@ -122,6 +123,7 @@ export function TimeInput({
updateTime(numVal.toString())
}}
type='text'
autoComplete='off'
/>
<span className='text-[#E6E6E6]'>:</span>
<Input
@@ -146,6 +148,7 @@ export function TimeInput({
updateTime(undefined, numVal.toString())
}}
type='text'
autoComplete='off'
/>
<Button
variant='outline'
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { AlertCircle, Code, FileJson, Trash2, X } from 'lucide-react'
import { AlertCircle, Code, FileJson, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Trash } from '@/components/emcn/icons/trash'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
AlertDialog,
@@ -1143,7 +1144,7 @@ try {
onClick={() => setShowDeleteConfirm(true)}
className='gap-1'
>
<Trash2 className='h-4 w-4' />
<Trash className='h-4 w-4' />
Delete
</Button>
) : (
@@ -1,6 +1,7 @@
import { useRef, useState } from 'react'
import { Plus, Trash } from 'lucide-react'
import { Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Trash } from '@/components/emcn/icons/trash'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -407,6 +408,7 @@ export function VariablesInput({
}
placeholder={`${assignment.type} value`}
disabled={isPreview || disabled}
autoComplete='off'
className={cn(
'h-9 border border-input bg-white text-transparent caret-foreground placeholder:text-muted-foreground/50 dark:border-input/60 dark:bg-background',
dragHighlight[assignment.id] && 'ring-2 ring-blue-500 ring-offset-2'
@@ -82,6 +82,7 @@ export function WebhookConfigField({
value={value}
onChange={onChange ? (e) => onChange(e.target.value) : undefined}
placeholder={placeholder}
autoComplete='off'
className={cn(
'h-10 flex-1',
readOnly ? 'cursor-text font-mono text-xs' : '',
@@ -25,15 +25,15 @@ import {
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 { Variables } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables'
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 { 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'
@@ -107,7 +107,7 @@ export function Panel() {
// Chat state
const { isChatOpen, setIsChatOpen } = useChatStore()
// const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesStore()
const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesStore()
const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null
@@ -285,10 +285,12 @@ 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 onClick={() => setVariablesOpen(!isVariablesOpen)}>
<Braces className='h-3 w-3' />
<span>Variables</span>
</PopoverItem>
}
{/* <PopoverItem>
<Bug className='h-3 w-3' />
<span>Debug</span>
@@ -469,7 +471,7 @@ export function Panel() {
</Modal>
{/* Floating Variables Modal */}
{/* <Variables /> */}
<Variables />
</>
)
}
@@ -1,8 +1,7 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Plus, Trash, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Plus, X } from 'lucide-react'
import Editor from 'react-simple-code-editor'
import type { ComboboxOption } from '@/components/emcn'
import {
@@ -16,16 +15,16 @@ import {
Input,
languages,
} from '@/components/emcn'
import { Label } from '@/components/ui/label'
import { createLogger } from '@/lib/logs/console/logger'
import { Label } from '@/components/emcn/components/label/label'
import { Trash } from '@/components/emcn/icons/trash'
import { cn, validateName } from '@/lib/utils'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useVariablesStore as usePanelVariablesStore } from '@/stores/panel/variables/store'
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
*/
@@ -37,6 +36,40 @@ const TYPE_OPTIONS: ComboboxOption[] = [
{ label: 'Array', value: 'array' },
]
/**
* UI constants for consistent styling and sizing
*/
const BADGE_HEIGHT = 20
const BADGE_TEXT_SIZE = 13
const ICON_SIZE = 14
const HEADER_ICON_SIZE = 16
const LINE_HEIGHT = 21
const MIN_EDITOR_HEIGHT = 120
/**
* User-facing strings for errors, labels, and placeholders
*/
const STRINGS = {
errors: {
emptyName: 'Variable name cannot be empty',
duplicateName: 'Two variables cannot have the same name',
},
labels: {
name: 'Name',
type: 'Type',
value: 'Value',
},
placeholders: {
name: 'variableName',
number: '42',
boolean: 'true',
plain: 'Plain text value',
object: '{\n "key": "value"\n}',
array: '[\n 1, 2, 3\n]',
},
emptyState: 'No variables yet',
}
/**
* Floating Variables modal component
*
@@ -46,29 +79,18 @@ const TYPE_OPTIONS: ComboboxOption[] = [
* - 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()
const { isOpen, position, width, height, setIsOpen, setPosition, setDimensions } =
useVariablesStore()
const { getVariablesByWorkflowId } = usePanelVariablesStore()
const { collaborativeUpdateVariable, collaborativeAddVariable, collaborativeDeleteVariable } =
useCollaborativeWorkflow()
const workflowVariables = activeWorkflowId ? getVariablesByWorkflowId(activeWorkflowId) : []
// Local UI helpers
const actualPosition = useMemo(
() => getVariablesPosition(position, width, height),
[position, width, height]
@@ -102,33 +124,31 @@ export function Variables() {
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>>({})
const cleanupState = useCallback(
(
setter: React.Dispatch<React.SetStateAction<Record<string, any>>>,
currentIds: Set<string>
) => {
setter((prev) => {
const filtered = Object.fromEntries(
Object.entries(prev).filter(([id]) => currentIds.has(id))
)
return Object.keys(filtered).length !== Object.keys(prev).length ? filtered : prev
})
},
[]
)
useEffect(() => {
const currentVariableIds = new Set(workflowVariables.map((v) => v.id))
cleanupState(setCollapsedById, currentVariableIds)
cleanupState(setLocalNames, currentVariableIds)
cleanupState(setNameErrors, currentVariableIds)
}, [workflowVariables, cleanupState])
/**
* Toggles the collapsed state of a variable
*/
const toggleCollapsed = (variableId: string) => {
setCollapsedById((prev) => ({
...prev,
@@ -136,26 +156,21 @@ export function Variables() {
}))
}
/**
* 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
})
const handleHeaderKeyDown = (e: React.KeyboardEvent, variableId: string) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
toggleCollapsed(variableId)
}
}
/**
* Clear error for a specific variable if present
*/
const clearError = (variableId: string) => {
const clearVariableState = (variableId: string, clearNames = true) => {
if (clearNames) {
setLocalNames((prev) => {
const updated = { ...prev }
delete updated[variableId]
return updated
})
}
setNameErrors((prev) => {
if (!prev[variableId]) return prev
const updated = { ...prev }
@@ -164,185 +179,195 @@ export function Variables() {
})
}
/**
* Adds a new variable to the list
*/
const handleAddVariable = () => {
const handleAddVariable = useCallback(() => {
if (!activeWorkflowId) return
addVariable({
collaborativeAddVariable({
name: '',
type: 'plain',
value: '',
workflowId: activeWorkflowId,
})
}
}, [activeWorkflowId, collaborativeAddVariable])
/**
* Removes a variable by ID
*/
const handleRemoveVariable = (variableId: string) => {
deleteVariable(variableId)
}
const handleRemoveVariable = useCallback(
(variableId: string) => {
collaborativeDeleteVariable(variableId)
},
[collaborativeDeleteVariable]
)
/**
* 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 })
}
const handleUpdateVariable = useCallback(
(variableId: string, field: 'name' | 'value' | 'type', value: any) => {
collaborativeUpdateVariable(variableId, field, value)
},
[collaborativeUpdateVariable]
)
const isDuplicateName = useCallback(
(variableId: string, name: string): boolean => {
const trimmedName = name.trim()
return (
!!trimmedName &&
workflowVariables.some((v) => v.id !== variableId && v.name === trimmedName)
)
},
[workflowVariables]
)
/**
* 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 handleVariableNameChange = useCallback((variableId: string, newName: string) => {
const validatedName = validateName(newName)
setLocalNames((prev) => ({
...prev,
[variableId]: validatedName,
}))
clearError(variableId)
}
clearVariableState(variableId, false)
}, [])
const handleVariableNameBlur = (variableId: string) => {
const localName = localNames[variableId]
if (localName === undefined) return
const handleVariableNameBlur = useCallback(
(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
}
const trimmedName = localName.trim()
if (!trimmedName) {
setNameErrors((prev) => ({
...prev,
[variableId]: STRINGS.errors.emptyName,
}))
return
}
if (isDuplicateName(variableId, trimmedName)) {
setNameErrors((prev) => ({
...prev,
[variableId]: 'Two variables cannot have the same name',
}))
return
}
if (isDuplicateName(variableId, trimmedName)) {
setNameErrors((prev) => ({
...prev,
[variableId]: STRINGS.errors.duplicateName,
}))
return
}
updateVariable(variableId, { name: trimmedName })
clearLocalState(variableId)
}
collaborativeUpdateVariable(variableId, 'name', trimmedName)
clearVariableState(variableId)
},
[localNames, isDuplicateName, collaborativeUpdateVariable]
)
const handleVariableNameKeyDown = (
variableId: string,
e: React.KeyboardEvent<HTMLInputElement>
) => {
const handleVariableNameKeyDown = (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 = () => {
setIsOpen(false)
}
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]'
const renderVariableHeader = useCallback(
(variable: Variable, index: number) => {
const isCollapsed = collapsedById[variable.id] ?? false
return (
<div
className='flex cursor-pointer items-center justify-between bg-transparent px-[10px] py-[5px]'
onClick={() => toggleCollapsed(variable.id)}
onKeyDown={(e) => handleHeaderKeyDown(e, variable.id)}
role='button'
tabIndex={0}
aria-expanded={!isCollapsed}
aria-controls={`variable-content-${variable.id}`}
>
<Trash className='h-[14px] w-[14px]' />
<span className='sr-only'>Delete Variable</span>
</Button>
</div>
</div>
<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 style={{ height: `${BADGE_HEIGHT}px`, fontSize: `${BADGE_TEXT_SIZE}px` }}>
{variable.type}
</Badge>
)}
</div>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
handleRemoveVariable(variable.id)
}}
className='h-auto p-0 text-[#EF4444] hover:text-[#EF4444]'
aria-label={`Delete ${variable.name || `variable ${index + 1}`}`}
>
<Trash style={{ width: `${ICON_SIZE}px`, height: `${ICON_SIZE}px` }} />
<span className='sr-only'>Delete Variable</span>
</Button>
</div>
)
},
[collapsedById, toggleCollapsed, handleRemoveVariable]
)
/**
* Renders the value input based on variable type
* Renders the value input based on variable type.
* Memoized with useCallback to prevent unnecessary re-renders.
*/
const renderValueInput = (variable: Variable) => {
const variableValue = formatValue(variable)
const renderValueInput = useCallback(
(variable: Variable) => {
const variableValue =
variable.value === ''
? ''
: typeof variable.value === 'string'
? variable.value
: JSON.stringify(variable.value)
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]'
if (variable.type === 'object' || variable.type === 'array') {
const lineCount = variableValue.split('\n').length
const gutterWidth = calculateGutterWidth(lineCount)
const placeholder =
variable.type === 'object' ? STRINGS.placeholders.object : STRINGS.placeholders.array
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>
))
const renderLineNumbers = () => {
return Array.from({ length: lineCount }, (_, i) => (
<div
key={i}
className='font-medium font-mono text-[#787878] text-xs'
style={{ height: `${LINE_HEIGHT}px`, lineHeight: `${LINE_HEIGHT}px` }}
>
{i + 1}
</div>
))
}
return (
<Code.Container style={{ minHeight: `${MIN_EDITOR_HEIGHT}px` }}>
<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 (
<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>
<Input
name='value'
autoComplete='off'
value={variableValue}
onChange={(e) => handleUpdateVariable(variable.id, 'value', e.target.value)}
placeholder={
variable.type === 'number'
? STRINGS.placeholders.number
: variable.type === 'boolean'
? STRINGS.placeholders.boolean
: STRINGS.placeholders.plain
}
/>
)
}
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'
}
/>
)
}
},
[handleUpdateVariable]
)
if (!isOpen) return null
@@ -376,11 +401,17 @@ export function Variables() {
e.stopPropagation()
handleAddVariable()
}}
aria-label='Add new variable'
>
<Plus className='h-[16px] w-[16px]' />
<Plus style={{ width: `${HEADER_ICON_SIZE}px`, height: `${HEADER_ICON_SIZE}px` }} />
</Button>
<Button variant='ghost' className='!p-1.5 -m-1.5' onClick={handleClose}>
<X className='h-[16px] w-[16px]' />
<Button
variant='ghost'
className='!p-1.5 -m-1.5'
onClick={handleClose}
aria-label='Close variables panel'
>
<X style={{ width: `${HEADER_ICON_SIZE}px`, height: `${HEADER_ICON_SIZE}px` }} />
</Button>
</div>
</div>
@@ -389,7 +420,7 @@ export function Variables() {
<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
{STRINGS.emptyState}
</div>
) : (
<div className='h-full overflow-y-auto overflow-x-hidden'>
@@ -405,24 +436,30 @@ export function Variables() {
{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
id={`variable-content-${variable.id}`}
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>
<Label className='text-[13px]'>{STRINGS.labels.name}</Label>
<Input
name='name'
autoComplete='off'
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'
onKeyDown={handleVariableNameKeyDown}
placeholder={STRINGS.placeholders.name}
/>
{nameErrors[variable.id] && (
<div className='mt-1 text-red-400 text-xs'>{nameErrors[variable.id]}</div>
<p className='text-[#EF4444] text-xs' role='alert'>
{nameErrors[variable.id]}
</p>
)}
</div>
<div className='space-y-[4px]'>
<Label className='text-[13px]'>Type</Label>
<Label className='text-[13px]'>{STRINGS.labels.type}</Label>
<Combobox
options={TYPE_OPTIONS}
value={variable.type}
@@ -431,7 +468,7 @@ export function Variables() {
</div>
<div className='space-y-[4px]'>
<Label className='text-[13px]'>Value</Label>
<Label className='text-[13px]'>{STRINGS.labels.value}</Label>
<div className='relative'>{renderValueInput(variable)}</div>
</div>
</div>
@@ -380,6 +380,15 @@ export function useCollaborativeWorkflow() {
payload.id
)
break
case 'variable-update':
if (payload.field === 'name') {
variablesStore.updateVariable(payload.variableId, { name: payload.value })
} else if (payload.field === 'value') {
variablesStore.updateVariable(payload.variableId, { value: payload.value })
} else if (payload.field === 'type') {
variablesStore.updateVariable(payload.variableId, { type: payload.value })
}
break
case 'remove':
variablesStore.deleteVariable(payload.variableId)
break
@@ -1527,6 +1536,8 @@ export function useCollaborativeWorkflow() {
const collaborativeAddVariable = useCallback(
(variableData: { name: string; type: any; value: any; workflowId: string }) => {
const id = crypto.randomUUID()
// Optimistically add to local store first
variablesStore.addVariable(variableData, id)
const processedVariable = useVariablesStore.getState().variables[id]
@@ -1537,6 +1548,8 @@ export function useCollaborativeWorkflow() {
name: processedVariable.name,
}
// Queue operation with processed name for server & other clients
// Empty callback because local store is already updated above
executeQueuedOperation('add', 'variable', payloadWithProcessedName, () => {})
}