diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index 7f2b800dc9..50ad0f94c1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -600,6 +600,7 @@ export function Chat() { onOutputSelect={handleOutputSelection} disabled={!activeWorkflowId} placeholder='Select outputs' + align='end' /> diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx index 1ca98e19b9..a8a9e0c807 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx @@ -7,7 +7,6 @@ import { Popover, PopoverContent, PopoverItem, - PopoverScrollArea, PopoverSection, PopoverTrigger, } from '@/components/emcn' @@ -24,6 +23,7 @@ interface OutputSelectProps { disabled?: boolean placeholder?: string valueMode?: 'id' | 'label' + align?: 'start' | 'end' | 'center' } export function OutputSelect({ @@ -33,10 +33,13 @@ export function OutputSelect({ disabled = false, placeholder = 'Select outputs', valueMode = 'id', + align = 'start', }: OutputSelectProps) { const [open, setOpen] = useState(false) + const [highlightedIndex, setHighlightedIndex] = useState(-1) const triggerRef = useRef(null) const popoverRef = useRef(null) + const contentRef = useRef(null) const blocks = useWorkflowStore((state) => state.blocks) const { isShowingDiff, isDiffReady, diffWorkflow } = useWorkflowDiffStore() const subBlockValues = useSubBlockStore((state) => @@ -230,6 +233,13 @@ export function OutputSelect({ return blockConfig?.bgColor || '#2F55FF' } + /** + * Flattened outputs for keyboard navigation + */ + const flattenedOutputs = useMemo(() => { + return Object.values(groupedOutputs).flat() + }, [groupedOutputs]) + /** * Handles output selection - toggle selection */ @@ -246,6 +256,75 @@ export function OutputSelect({ onOutputSelect(newSelectedOutputs) } + /** + * Keyboard navigation handler + */ + const handleKeyDown = (e: React.KeyboardEvent) => { + if (flattenedOutputs.length === 0) return + + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + setHighlightedIndex((prev) => { + const next = prev < flattenedOutputs.length - 1 ? prev + 1 : 0 + return next + }) + break + + case 'ArrowUp': + e.preventDefault() + setHighlightedIndex((prev) => { + const next = prev > 0 ? prev - 1 : flattenedOutputs.length - 1 + return next + }) + break + + case 'Enter': + e.preventDefault() + if (highlightedIndex >= 0 && highlightedIndex < flattenedOutputs.length) { + handleOutputSelection(flattenedOutputs[highlightedIndex].label) + } + break + + case 'Escape': + e.preventDefault() + setOpen(false) + break + } + } + + /** + * Reset highlighted index when popover opens/closes + */ + useEffect(() => { + if (open) { + // Find first selected item, or start at -1 + const firstSelectedIndex = flattenedOutputs.findIndex((output) => isSelectedValue(output)) + setHighlightedIndex(firstSelectedIndex >= 0 ? firstSelectedIndex : -1) + + // Focus the content for keyboard navigation + setTimeout(() => { + contentRef.current?.focus() + }, 0) + } else { + setHighlightedIndex(-1) + } + }, [open, flattenedOutputs]) + + /** + * Scroll highlighted item into view + */ + useEffect(() => { + if (highlightedIndex >= 0 && contentRef.current) { + const highlightedElement = contentRef.current.querySelector( + `[data-option-index="${highlightedIndex}"]` + ) + if (highlightedElement) { + highlightedElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + } + } + }, [highlightedIndex]) + /** * Closes popover when clicking outside */ @@ -288,44 +367,57 @@ export function OutputSelect({ e.preventDefault()} - onCloseAutoFocus={(e) => e.preventDefault()} + maxHeight={300} + maxWidth={300} + minWidth={200} + onKeyDown={handleKeyDown} + tabIndex={0} + style={{ outline: 'none' }} > - - {Object.entries(groupedOutputs).map(([blockName, outputs]) => ( -
- {blockName} - -
- {outputs.map((output) => ( - handleOutputSelection(output.label)} - > -
- - {blockName.charAt(0).toUpperCase()} - -
- {output.path} - {isSelectedValue(output) && } -
- ))} +
+ {Object.entries(groupedOutputs).map(([blockName, outputs]) => { + // Calculate the starting index for this group + const startIndex = flattenedOutputs.findIndex((o) => o.blockName === blockName) + + return ( +
+ {blockName} + +
+ {outputs.map((output, localIndex) => { + const globalIndex = startIndex + localIndex + const isHighlighted = globalIndex === highlightedIndex + + return ( + handleOutputSelection(output.label)} + onMouseEnter={() => setHighlightedIndex(globalIndex)} + > +
+ + {blockName.charAt(0).toUpperCase()} + +
+ {output.path} + {isSelectedValue(output) && } +
+ ) + })} +
-
- ))} - + ) + })} +
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/input-mapping/input-mapping.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/input-mapping/input-mapping.tsx index 82f4581cd6..ceeddd7099 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/input-mapping/input-mapping.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/input-mapping/input-mapping.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react' +import { Badge } from '@/components/emcn' import { Input } from '@/components/emcn/components/input/input' import { Label } from '@/components/ui/label' import { cn } from '@/lib/utils' @@ -50,12 +51,13 @@ interface InputMappingFieldProps { value: string onChange: (value: string) => void blockId: string - subBlockId: string disabled: boolean accessiblePrefixes: Set | undefined inputController: ReturnType - inputRefs: React.MutableRefObject> - overlayRefs: React.MutableRefObject> + inputRefs: React.RefObject> + overlayRefs: React.RefObject> + collapsed: boolean + onToggleCollapse: () => void } /** @@ -169,6 +171,7 @@ export function InputMapping({ const [childInputFields, setChildInputFields] = useState([]) const [isLoading, setIsLoading] = useState(false) + const [collapsedFields, setCollapsedFields] = useState>({}) useEffect(() => { let isMounted = true @@ -245,6 +248,13 @@ export function InputMapping({ setMapping(updated) } + const toggleCollapse = (fieldName: string) => { + setCollapsedFields((prev) => ({ + ...prev, + [fieldName]: !prev[fieldName], + })) + } + if (!selectedWorkflowId) { return (
@@ -278,12 +288,13 @@ export function InputMapping({ value='' onChange={() => {}} blockId={blockId} - subBlockId={subBlockId} disabled={true} accessiblePrefixes={accessiblePrefixes} inputController={inputController} inputRefs={inputRefs} overlayRefs={overlayRefs} + collapsed={false} + onToggleCollapse={() => {}} />
) @@ -303,12 +314,13 @@ export function InputMapping({ value={valueObj[field.name] || ''} onChange={(value) => handleFieldUpdate(field.name, value)} blockId={blockId} - subBlockId={subBlockId} disabled={isPreview || disabled} accessiblePrefixes={accessiblePrefixes} inputController={inputController} inputRefs={inputRefs} overlayRefs={overlayRefs} + collapsed={collapsedFields[field.name] || false} + onToggleCollapse={() => toggleCollapse(field.name)} /> ))}
@@ -326,12 +338,13 @@ function InputMappingField({ value, onChange, blockId, - subBlockId, disabled, accessiblePrefixes, inputController, inputRefs, overlayRefs, + collapsed, + onToggleCollapse, }: InputMappingFieldProps) { const fieldId = fieldName const fieldState = inputController.fieldHelpers.getFieldState(fieldId) @@ -354,64 +367,91 @@ function InputMappingField({ } return ( -
-
- - {fieldType && ( - - {fieldType} +
+
+
+ + {fieldName} - )} + {fieldType && {fieldType}} +
-
- { - if (el) inputRefs.current.set(fieldId, el) - }} - className={cn( - 'allow-scroll !bg-transparent w-full overflow-auto rounded-none border-0 px-[10px] py-[8px] text-transparent caret-white [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0 [&::-webkit-scrollbar]:hidden' - )} - type='text' - value={value} - onChange={handlers.onChange} - onKeyDown={handlers.onKeyDown} - onScroll={handleScroll} - onDrop={handlers.onDrop} - onDragOver={handlers.onDragOver} - autoComplete='off' - disabled={disabled} - /> -
{ - if (el) overlayRefs.current.set(fieldId, el) - }} - className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[10px] py-[8px] font-medium font-sans text-[#eeeeee] text-sm [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden' - > -
- {formatDisplayText(value, { - accessiblePrefixes, - highlightAll: !accessiblePrefixes, - })} + + {!collapsed && ( +
+
+ +
+ { + if (el) inputRefs.current.set(fieldId, el) + }} + name='value' + value={value} + onChange={handlers.onChange} + onKeyDown={handlers.onKeyDown} + onDrop={handlers.onDrop} + onDragOver={handlers.onDragOver} + onScroll={(e) => handleScroll(e)} + onPaste={() => + setTimeout(() => { + const input = inputRefs.current.get(fieldId) + input && handleScroll({ currentTarget: input } as any) + }, 0) + } + placeholder='Enter value or reference' + disabled={disabled} + autoComplete='off' + className={cn( + 'allow-scroll w-full overflow-auto text-transparent caret-foreground' + )} + style={{ overflowX: 'auto' }} + /> +
{ + if (el) overlayRefs.current.set(fieldId, el) + }} + className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm' + style={{ overflowX: 'auto' }} + > +
+ {formatDisplayText( + value, + accessiblePrefixes ? { accessiblePrefixes } : { highlightAll: true } + )} +
+
+ {fieldState.showTags && ( + inputController.fieldHelpers.hideFieldDropdowns(fieldId)} + inputRef={ + { + current: inputRefs.current.get(fieldId) || null, + } as React.RefObject + } + /> + )} +
- - {fieldState.showTags && ( - inputController.fieldHelpers.hideFieldDropdowns(fieldId)} - inputRef={ - { - current: inputRefs.current.get(fieldId) || null, - } as React.RefObject - } - /> - )} -
+ )}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/variables-input/variables-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/variables-input/variables-input.tsx index 357f01d705..9113a437bb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/variables-input/variables-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/variables-input/variables-input.tsx @@ -1,17 +1,9 @@ -import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Plus } from 'lucide-react' import { useParams } from 'next/navigation' +import { Badge, Button, Combobox, Input } from '@/components/emcn' 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' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' import { Textarea } from '@/components/ui/textarea' import { cn } from '@/lib/utils' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text' @@ -68,6 +60,7 @@ export function VariablesInput({ const valueInputRefs = useRef>({}) const overlayRefs = useRef>({}) const [dragHighlight, setDragHighlight] = useState>({}) + const [collapsedAssignments, setCollapsedAssignments] = useState>({}) const currentWorkflowVariables = Object.values(workflowVariables).filter( (v: Variable) => v.workflowId === workflowId @@ -75,6 +68,7 @@ export function VariablesInput({ const value = isPreview ? previewValue : storeValue const assignments: VariableAssignment[] = value || [] + const isReadOnly = isPreview || disabled const getAvailableVariablesFor = (currentAssignmentId: string) => { const otherSelectedIds = new Set( @@ -91,8 +85,41 @@ export function VariablesInput({ const allVariablesAssigned = !hasNoWorkflowVariables && getAvailableVariablesFor('new').length === 0 + // Initialize with one empty assignment if none exist and not in preview/disabled mode + // Also add assignment when first variable is created + useEffect(() => { + if (!isReadOnly && assignments.length === 0 && currentWorkflowVariables.length > 0) { + const initialAssignment: VariableAssignment = { + ...DEFAULT_ASSIGNMENT, + id: crypto.randomUUID(), + } + setStoreValue([initialAssignment]) + } + }, [currentWorkflowVariables.length, isReadOnly, assignments.length, setStoreValue]) + + // Clean up assignments when their associated variables are deleted + useEffect(() => { + if (isReadOnly || assignments.length === 0) return + + const currentVariableIds = new Set(currentWorkflowVariables.map((v) => v.id)) + const validAssignments = assignments.filter((assignment) => { + // Keep assignments that haven't selected a variable yet + if (!assignment.variableId) return true + // Keep assignments whose variable still exists + return currentVariableIds.has(assignment.variableId) + }) + + // If all variables were deleted, clear all assignments + if (currentWorkflowVariables.length === 0) { + setStoreValue([]) + } else if (validAssignments.length !== assignments.length) { + // Some assignments reference deleted variables, remove them + setStoreValue(validAssignments.length > 0 ? validAssignments : []) + } + }, [currentWorkflowVariables, assignments, isReadOnly, setStoreValue]) + const addAssignment = () => { - if (isPreview || disabled) return + if (isPreview || disabled || allVariablesAssigned) return const newAssignment: VariableAssignment = { ...DEFAULT_ASSIGNMENT, @@ -219,6 +246,13 @@ export function VariablesInput({ setDragHighlight((prev) => ({ ...prev, [assignmentId]: false })) } + const toggleCollapse = (assignmentId: string) => { + setCollapsedAssignments((prev) => ({ + ...prev, + [assignmentId]: !prev[assignmentId], + })) + } + if (isPreview && (!assignments || assignments.length === 0)) { return (
@@ -244,225 +278,195 @@ export function VariablesInput({ } if (!isPreview && hasNoWorkflowVariables && assignments.length === 0) { - return ( -
- - - -

No variables found

-

- Add variables in the Variables panel to get started -

-
- ) + return

No variables available

} return ( -
- {assignments && assignments.length > 0 ? ( -
- {assignments.map((assignment) => { +
+ {assignments && assignments.length > 0 && ( +
+ {assignments.map((assignment, index) => { + const collapsed = collapsedAssignments[assignment.id] || false + const availableVars = getAvailableVariablesFor(assignment.id) + return (
- {!isPreview && !disabled && ( - - )} - -
-
-
- - {assignment.variableName && ( - - {assignment.type} - - )} -
- + + Add Variable + +
+
-
- - {assignment.type === 'object' || assignment.type === 'array' ? ( -
-