Skip to content

Commit 81e6410

Browse files
committed
improvement(tag-dropdown): added option to select block in tag dropdown, custom tools modal improvements, light mode fixes
1 parent 7793a6d commit 81e6410

File tree

7 files changed

+219
-154
lines changed

7 files changed

+219
-154
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx

Lines changed: 128 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
'use client'
22

3-
import { useEffect, useMemo, useRef, useState } from 'react'
4-
import { Check } from 'lucide-react'
3+
import type React from 'react'
4+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
5+
import { Check, RepeatIcon, SplitIcon } from 'lucide-react'
56
import {
67
Badge,
78
Popover,
@@ -19,6 +20,32 @@ import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
1920
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
2021
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
2122

23+
/**
24+
* Renders a tag icon with background color.
25+
*
26+
* @param icon - Either a letter string or a Lucide icon component
27+
* @param color - Background color for the icon container
28+
* @returns A styled icon element
29+
*/
30+
const TagIcon: React.FC<{
31+
icon: string | React.ComponentType<{ className?: string }>
32+
color: string
33+
}> = ({ icon, color }) => (
34+
<div
35+
className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded'
36+
style={{ background: color }}
37+
>
38+
{typeof icon === 'string' ? (
39+
<span className='!text-white font-bold text-[10px]'>{icon}</span>
40+
) : (
41+
(() => {
42+
const IconComponent = icon
43+
return <IconComponent className='!text-white size-[9px]' />
44+
})()
45+
)}
46+
</div>
47+
)
48+
2249
/**
2350
* Props for the OutputSelect component
2451
*/
@@ -71,7 +98,6 @@ export function OutputSelect({
7198
const [highlightedIndex, setHighlightedIndex] = useState(-1)
7299
const triggerRef = useRef<HTMLDivElement>(null)
73100
const popoverRef = useRef<HTMLDivElement>(null)
74-
const contentRef = useRef<HTMLDivElement>(null)
75101
const blocks = useWorkflowStore((state) => state.blocks)
76102
const { isShowingDiff, isDiffReady, hasActiveDiff, baselineWorkflow } = useWorkflowDiffStore()
77103
const subBlockValues = useSubBlockStore((state) =>
@@ -185,8 +211,11 @@ export function OutputSelect({
185211
* @param o - The output object to check
186212
* @returns True if the output is selected, false otherwise
187213
*/
188-
const isSelectedValue = (o: { id: string; label: string }) =>
189-
selectedOutputs.includes(o.id) || selectedOutputs.includes(o.label)
214+
const isSelectedValue = useCallback(
215+
(o: { id: string; label: string }) =>
216+
selectedOutputs.includes(o.id) || selectedOutputs.includes(o.label),
217+
[selectedOutputs]
218+
)
190219

191220
/**
192221
* Gets display text for selected outputs
@@ -292,82 +321,96 @@ export function OutputSelect({
292321
* Handles output selection by toggling the selected state
293322
* @param value - The output label to toggle
294323
*/
295-
const handleOutputSelection = (value: string) => {
296-
const emittedValue =
297-
valueMode === 'label' ? value : workflowOutputs.find((o) => o.label === value)?.id || value
298-
const index = selectedOutputs.indexOf(emittedValue)
299-
300-
const newSelectedOutputs =
301-
index === -1
302-
? [...new Set([...selectedOutputs, emittedValue])]
303-
: selectedOutputs.filter((id) => id !== emittedValue)
304-
305-
onOutputSelect(newSelectedOutputs)
306-
}
324+
const handleOutputSelection = useCallback(
325+
(value: string) => {
326+
const emittedValue =
327+
valueMode === 'label' ? value : workflowOutputs.find((o) => o.label === value)?.id || value
328+
const index = selectedOutputs.indexOf(emittedValue)
329+
330+
const newSelectedOutputs =
331+
index === -1
332+
? [...new Set([...selectedOutputs, emittedValue])]
333+
: selectedOutputs.filter((id) => id !== emittedValue)
334+
335+
onOutputSelect(newSelectedOutputs)
336+
},
337+
[valueMode, workflowOutputs, selectedOutputs, onOutputSelect]
338+
)
307339

308340
/**
309341
* Handles keyboard navigation within the output list
310342
* Supports ArrowUp, ArrowDown, Enter, and Escape keys
311-
* @param e - Keyboard event
312343
*/
313-
const handleKeyDown = (e: React.KeyboardEvent) => {
314-
if (flattenedOutputs.length === 0) return
315-
316-
switch (e.key) {
317-
case 'ArrowDown':
318-
e.preventDefault()
319-
setHighlightedIndex((prev) => {
320-
const next = prev < flattenedOutputs.length - 1 ? prev + 1 : 0
321-
return next
322-
})
323-
break
324-
325-
case 'ArrowUp':
326-
e.preventDefault()
327-
setHighlightedIndex((prev) => {
328-
const next = prev > 0 ? prev - 1 : flattenedOutputs.length - 1
329-
return next
330-
})
331-
break
332-
333-
case 'Enter':
334-
e.preventDefault()
335-
if (highlightedIndex >= 0 && highlightedIndex < flattenedOutputs.length) {
336-
handleOutputSelection(flattenedOutputs[highlightedIndex].label)
337-
}
338-
break
344+
useEffect(() => {
345+
if (!open || flattenedOutputs.length === 0) return
346+
347+
const handleKeyboardEvent = (e: KeyboardEvent) => {
348+
switch (e.key) {
349+
case 'ArrowDown':
350+
e.preventDefault()
351+
e.stopPropagation()
352+
setHighlightedIndex((prev) => {
353+
// If no selection or at end, go to start. Otherwise increment
354+
if (prev === -1 || prev >= flattenedOutputs.length - 1) {
355+
return 0
356+
}
357+
return prev + 1
358+
})
359+
break
360+
361+
case 'ArrowUp':
362+
e.preventDefault()
363+
e.stopPropagation()
364+
setHighlightedIndex((prev) => {
365+
// If no selection or at start, go to end. Otherwise decrement
366+
if (prev <= 0) {
367+
return flattenedOutputs.length - 1
368+
}
369+
return prev - 1
370+
})
371+
break
372+
373+
case 'Enter':
374+
e.preventDefault()
375+
e.stopPropagation()
376+
setHighlightedIndex((currentIndex) => {
377+
if (currentIndex >= 0 && currentIndex < flattenedOutputs.length) {
378+
handleOutputSelection(flattenedOutputs[currentIndex].label)
379+
}
380+
return currentIndex
381+
})
382+
break
339383

340-
case 'Escape':
341-
e.preventDefault()
342-
setOpen(false)
343-
break
384+
case 'Escape':
385+
e.preventDefault()
386+
e.stopPropagation()
387+
setOpen(false)
388+
break
389+
}
344390
}
345-
}
391+
392+
window.addEventListener('keydown', handleKeyboardEvent, true)
393+
return () => window.removeEventListener('keydown', handleKeyboardEvent, true)
394+
}, [open, flattenedOutputs, handleOutputSelection])
346395

347396
/**
348397
* Reset highlighted index when popover opens/closes
349398
*/
350399
useEffect(() => {
351400
if (open) {
352-
// Find first selected item, or start at -1
353401
const firstSelectedIndex = flattenedOutputs.findIndex((output) => isSelectedValue(output))
354402
setHighlightedIndex(firstSelectedIndex >= 0 ? firstSelectedIndex : -1)
355-
356-
// Focus the content for keyboard navigation
357-
setTimeout(() => {
358-
contentRef.current?.focus()
359-
}, 0)
360403
} else {
361404
setHighlightedIndex(-1)
362405
}
363-
}, [open, flattenedOutputs])
406+
}, [open, flattenedOutputs, isSelectedValue])
364407

365408
/**
366409
* Scroll highlighted item into view
367410
*/
368411
useEffect(() => {
369-
if (highlightedIndex >= 0 && contentRef.current) {
370-
const highlightedElement = contentRef.current.querySelector(
412+
if (highlightedIndex >= 0 && popoverRef.current) {
413+
const highlightedElement = popoverRef.current.querySelector(
371414
`[data-option-index="${highlightedIndex}"]`
372415
)
373416
if (highlightedElement) {
@@ -425,18 +468,37 @@ export function OutputSelect({
425468
minWidth={160}
426469
border
427470
disablePortal={disablePopoverPortal}
428-
onKeyDown={handleKeyDown}
429-
tabIndex={0}
430-
style={{ outline: 'none' }}
431471
>
432-
<div ref={contentRef} className='space-y-[2px]'>
472+
<div className='space-y-[2px]'>
433473
{Object.entries(groupedOutputs).map(([blockName, outputs]) => {
434474
// Calculate the starting index for this group
435475
const startIndex = flattenedOutputs.findIndex((o) => o.blockName === blockName)
436476

477+
const firstOutput = outputs[0]
478+
const blockConfig = getBlock(firstOutput.blockType)
479+
const blockColor = getOutputColor(firstOutput.blockId, firstOutput.blockType)
480+
481+
// Determine the icon to use
482+
let blockIcon: string | React.ComponentType<{ className?: string }> = blockName
483+
.charAt(0)
484+
.toUpperCase()
485+
486+
if (blockConfig?.icon) {
487+
blockIcon = blockConfig.icon
488+
} else if (firstOutput.blockType === 'loop') {
489+
blockIcon = RepeatIcon
490+
} else if (firstOutput.blockType === 'parallel') {
491+
blockIcon = SplitIcon
492+
}
493+
437494
return (
438495
<div key={blockName}>
439-
<PopoverSection>{blockName}</PopoverSection>
496+
<PopoverSection>
497+
<div className='flex items-center gap-1.5'>
498+
<TagIcon icon={blockIcon} color={blockColor} />
499+
<span>{blockName}</span>
500+
</div>
501+
</PopoverSection>
440502

441503
<div className='flex flex-col gap-[2px]'>
442504
{outputs.map((output, localIndex) => {
@@ -451,17 +513,9 @@ export function OutputSelect({
451513
onClick={() => handleOutputSelection(output.label)}
452514
onMouseEnter={() => setHighlightedIndex(globalIndex)}
453515
>
454-
<div
455-
className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded'
456-
style={{
457-
backgroundColor: getOutputColor(output.blockId, output.blockType),
458-
}}
459-
>
460-
<span className='font-bold text-[10px] text-white'>
461-
{blockName.charAt(0).toUpperCase()}
462-
</span>
463-
</div>
464-
<span className='min-w-0 flex-1 truncate'>{output.path}</span>
516+
<span className='min-w-0 flex-1 truncate text-[var(--text-primary)]'>
517+
{output.path}
518+
</span>
465519
{isSelectedValue(output) && <Check className='h-3 w-3 flex-shrink-0' />}
466520
</PopoverItem>
467521
)

0 commit comments

Comments
 (0)