mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(blocks): give a block one tile everywhere it is listed (#6634)
A block's tile disagreed with the card it named. The canvas brands only third-party integrations and gives everything first-party its role accent, but the command palette, connection lists, tag menus and output pickers all painted straight from the catalog `bgColor` — so Webhook Trigger showed green in the palette and blue on the canvas it was about to be dropped onto, and the five roleless first-party triggers showed catalog blues where the canvas shows neutral. Read the canvas rule from one place (`hasBlockAccent`) and render it through one component (`BlockTile`), then point every surface that lists a block at them: canvas, editor header, preview, toolbar, palette, connection picker, terminal, logs trace rows, connection lists, tag menus, output pickers and the tables workflow sidebar. Folds in the duplication the split had grown: three copies of `TagIcon`, five hand-rolled tile divs, the toolbar's second encoding of the accent rule, a third icon-contrast helper on its own brightness threshold, and the dead `showColoredIcon` prop every caller passed. Tiles now share the chip radius, and the tile forces its own icon colour so popover and command rows painting `[&_svg]:text-*` can no longer wash out a pale brand tile. Large detail headers (preview panel, trace-view detail) keep their own treatment and are left for a follow-up.
This commit is contained in:
+11
-8
@@ -42,11 +42,12 @@ import {
|
||||
getDisplayName,
|
||||
hasErrorInTree,
|
||||
hasUnhandledErrorInTree,
|
||||
iconColorClass,
|
||||
isIterationType,
|
||||
parseTime,
|
||||
} from '@/app/workspace/[workspaceId]/logs/components/log-details/utils'
|
||||
import { BlockTile } from '@/blocks/block-tile'
|
||||
import { isCustomBlockType } from '@/blocks/custom/build-config'
|
||||
import { getTileIconColorClass } from '@/blocks/icon-color'
|
||||
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
|
||||
|
||||
const DEFAULT_TREE_PANE_WIDTH = 240
|
||||
@@ -331,12 +332,12 @@ const TraceTreeRow = memo(function TraceTreeRow({
|
||||
<div className='size-[14px] flex-shrink-0' />
|
||||
)}
|
||||
{!isIterationType(span.type) && (
|
||||
<div
|
||||
className='flex size-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm [&_img]:size-full'
|
||||
style={{ background: bgColor }}
|
||||
>
|
||||
{BlockIcon && <BlockIcon className={cn('size-[10px]', iconColorClass(bgColor))} />}
|
||||
</div>
|
||||
<BlockTile
|
||||
blockType={span.type?.toLowerCase() ?? ''}
|
||||
icon={BlockIcon ?? undefined}
|
||||
bgColor={bgColor}
|
||||
size='sm'
|
||||
/>
|
||||
)}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
@@ -711,7 +712,9 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa
|
||||
className='mt-[2px] flex size-[18px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm [&_img]:size-full'
|
||||
style={{ background: bgColor }}
|
||||
>
|
||||
{BlockIcon && <BlockIcon className={cn('size-[12px]', iconColorClass(bgColor))} />}
|
||||
{BlockIcon && (
|
||||
<BlockIcon className={cn('size-[12px]', getTileIconColorClass(bgColor))} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
|
||||
|
||||
@@ -86,12 +86,6 @@ export function getBlockIconAndColor(
|
||||
*/
|
||||
const MAX_YIQ_SUM = 255_000
|
||||
|
||||
/** Returns 'text-white' for dark backgrounds, dark text for light ones. */
|
||||
export function iconColorClass(bgColor: string): string {
|
||||
const brightness = perceivedBrightness(bgColor)
|
||||
return brightness !== null && brightness > 160_000 / MAX_YIQ_SUM ? 'text-[#111111]' : 'text-white'
|
||||
}
|
||||
|
||||
/**
|
||||
* Near-black bgColors disappear against the dark-mode surface (--bg: #1b1b1b).
|
||||
* Below the brightness threshold we fall back to the neutral block color used
|
||||
|
||||
+4
-2
@@ -7,10 +7,10 @@ import type { EnrichmentProviderOutcome, EnrichmentRunDetail } from '@/lib/table
|
||||
import {
|
||||
adjustBgForContrast,
|
||||
getBlockIconAndColor,
|
||||
iconColorClass,
|
||||
} from '@/app/workspace/[workspaceId]/logs/components/log-details/utils'
|
||||
import { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks'
|
||||
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import { getTileIconColorClass } from '@/blocks/icon-color'
|
||||
import { useEnrichmentDetail } from '@/hooks/queries/tables'
|
||||
import { formatCost } from '@/providers/utils'
|
||||
import { useLogDetailsUIStore } from '@/stores/logs/store'
|
||||
@@ -255,7 +255,9 @@ function EnrichmentDetailsContent({
|
||||
style={{ background: bgColor }}
|
||||
>
|
||||
{ProviderIcon && (
|
||||
<ProviderIcon className={cn('size-[11px]', iconColorClass(bgColor))} />
|
||||
<ProviderIcon
|
||||
className={cn('size-[11px]', getTileIconColorClass(bgColor))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className='min-w-0 flex-1 truncate text-[var(--text-secondary)] text-caption'>
|
||||
|
||||
+7
-36
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
@@ -18,7 +17,7 @@ import {
|
||||
Tooltip,
|
||||
toast,
|
||||
} from '@sim/emcn'
|
||||
import { ArrowLeft, ChevronDown, Repeat, Split, SquareArrowUpRight, X } from '@sim/emcn/icons'
|
||||
import { ArrowLeft, ChevronDown, SquareArrowUpRight, X } from '@sim/emcn/icons'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
@@ -57,8 +56,7 @@ import {
|
||||
RequiredLabel,
|
||||
} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields'
|
||||
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { getTileIconColorClass } from '@/blocks/icon-color'
|
||||
import { BlockTile } from '@/blocks/block-tile'
|
||||
import {
|
||||
useAddWorkflowGroup,
|
||||
useUpdateColumn,
|
||||
@@ -140,8 +138,6 @@ interface BlockOutputGroup {
|
||||
blockId: string
|
||||
blockName: string
|
||||
blockType: string
|
||||
blockIcon: string | React.ComponentType<{ className?: string }>
|
||||
blockColor: string
|
||||
paths: string[]
|
||||
}
|
||||
|
||||
@@ -164,25 +160,6 @@ function tableColumnTypeToInputType(colType: ColumnDefinition['type'] | undefine
|
||||
return columnTypeById(colType).workflowInputType
|
||||
}
|
||||
|
||||
const TagIcon: React.FC<{
|
||||
icon: string | React.ComponentType<{ className?: string }>
|
||||
color: string
|
||||
}> = ({ icon, color }) => (
|
||||
<div
|
||||
className='flex size-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded [&_img]:size-full'
|
||||
style={{ background: color }}
|
||||
>
|
||||
{typeof icon === 'string' ? (
|
||||
<span className={cn(getTileIconColorClass(color, true), 'font-bold text-micro')}>{icon}</span>
|
||||
) : (
|
||||
(() => {
|
||||
const IconComponent = icon
|
||||
return <IconComponent className={cn(getTileIconColorClass(color, true), 'size-[9px]')} />
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
/**
|
||||
* Right-edge sidebar for workflow group configuration. Three flows:
|
||||
* - create a new group (workflow + outputs + deps),
|
||||
@@ -468,20 +445,10 @@ export function WorkflowSidebarBody({
|
||||
for (const f of flat) {
|
||||
let group = groupsByBlockId.get(f.blockId)
|
||||
if (!group) {
|
||||
const blockConfig = getBlock(f.blockType)
|
||||
const blockColor = blockConfig?.bgColor || '#2F55FF'
|
||||
let blockIcon: string | React.ComponentType<{ className?: string }> = f.blockName
|
||||
.charAt(0)
|
||||
.toUpperCase()
|
||||
if (blockConfig?.icon) blockIcon = blockConfig.icon
|
||||
else if (f.blockType === 'loop') blockIcon = Repeat
|
||||
else if (f.blockType === 'parallel') blockIcon = Split
|
||||
group = {
|
||||
blockId: f.blockId,
|
||||
blockName: f.blockName,
|
||||
blockType: f.blockType,
|
||||
blockIcon,
|
||||
blockColor,
|
||||
paths: [],
|
||||
}
|
||||
groupsByBlockId.set(f.blockId, group)
|
||||
@@ -504,7 +471,11 @@ export function WorkflowSidebarBody({
|
||||
section: group.blockName,
|
||||
sectionElement: (
|
||||
<div className='flex items-center gap-1.5 px-1.5 pt-1.5 pb-1'>
|
||||
<TagIcon icon={group.blockIcon} color={group.blockColor} />
|
||||
<BlockTile
|
||||
blockType={group.blockType}
|
||||
fallbackLabel={group.blockName.charAt(0).toUpperCase()}
|
||||
size='sm'
|
||||
/>
|
||||
<span className='text-[var(--text-secondary)] text-caption'>{group.blockName}</span>
|
||||
</div>
|
||||
),
|
||||
|
||||
+6
-55
@@ -1,47 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { ChipCombobox, Combobox, type ComboboxOptionGroup, cn } from '@sim/emcn'
|
||||
import { Repeat, Split } from '@sim/emcn/icons'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
type FlattenOutputsBlockInput,
|
||||
flattenWorkflowOutputs,
|
||||
} from '@/lib/workflows/blocks/flatten-outputs'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { getTileIconColorClass } from '@/blocks/icon-color'
|
||||
import { BlockTile } from '@/blocks/block-tile'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
/**
|
||||
* Renders a tag icon with background color for block section headers.
|
||||
*
|
||||
* @param icon - Either a letter string or a Lucide icon component
|
||||
* @param color - Background color for the icon container
|
||||
* @returns A styled icon element
|
||||
*/
|
||||
const TagIcon: React.FC<{
|
||||
icon: string | React.ComponentType<{ className?: string }>
|
||||
color: string
|
||||
}> = ({ icon, color }) => (
|
||||
<div
|
||||
className='flex size-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded [&_img]:size-full'
|
||||
style={{ background: color }}
|
||||
>
|
||||
{typeof icon === 'string' ? (
|
||||
<span className={cn(getTileIconColorClass(color, true), 'font-bold text-micro')}>{icon}</span>
|
||||
) : (
|
||||
(() => {
|
||||
const IconComponent = icon
|
||||
return <IconComponent className={cn(getTileIconColorClass(color, true), 'size-[9px]')} />
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const EMPTY_OUTPUTS: string[] = []
|
||||
|
||||
/**
|
||||
@@ -200,16 +171,6 @@ export function OutputSelect({
|
||||
return `${validOutputs.length} outputs`
|
||||
}, [selectedOutputs, workflowOutputs, placeholder])
|
||||
|
||||
/**
|
||||
* Gets the background color for a block output based on its type
|
||||
* @param blockType - The type of the block
|
||||
* @returns The hex color code for the block
|
||||
*/
|
||||
const getOutputColor = (blockType: string) => {
|
||||
const blockConfig = getBlock(blockType)
|
||||
return blockConfig?.bgColor || '#2F55FF'
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups outputs by block and sorts by distance from starter block.
|
||||
* Returns ComboboxOptionGroup[] for use with Combobox.
|
||||
@@ -261,25 +222,15 @@ export function OutputSelect({
|
||||
|
||||
return sortedGroups.map(({ blockName, outputs }) => {
|
||||
const firstOutput = outputs[0]
|
||||
const blockConfig = getBlock(firstOutput.blockType)
|
||||
const blockColor = getOutputColor(firstOutput.blockType)
|
||||
|
||||
let blockIcon: string | React.ComponentType<{ className?: string }> = blockName
|
||||
.charAt(0)
|
||||
.toUpperCase()
|
||||
|
||||
if (blockConfig?.icon) {
|
||||
blockIcon = blockConfig.icon
|
||||
} else if (firstOutput.blockType === 'loop') {
|
||||
blockIcon = Repeat
|
||||
} else if (firstOutput.blockType === 'parallel') {
|
||||
blockIcon = Split
|
||||
}
|
||||
|
||||
return {
|
||||
sectionElement: (
|
||||
<div className='flex items-center gap-1.5 px-1.5 py-1'>
|
||||
<TagIcon icon={blockIcon} color={blockColor} />
|
||||
<BlockTile
|
||||
blockType={firstOutput.blockType}
|
||||
fallbackLabel={blockName.charAt(0).toUpperCase()}
|
||||
size='sm'
|
||||
/>
|
||||
<span className='text-small'>{blockName}</span>
|
||||
</div>
|
||||
),
|
||||
|
||||
+5
-5
@@ -433,8 +433,9 @@ export function ConnectionBlockSelector({ id, data }: NodeProps<ConnectionBlockS
|
||||
}}
|
||||
icon={result.item.icon}
|
||||
bgColor={result.item.bgColor}
|
||||
showColoredIcon
|
||||
workflowType={result.kind === 'block' ? result.item.type : undefined}
|
||||
blockType={
|
||||
result.kind === 'tool_operation' ? result.item.blockType : result.item.type
|
||||
}
|
||||
label={result.item.name}
|
||||
/>
|
||||
))}
|
||||
@@ -453,7 +454,7 @@ export function ConnectionBlockSelector({ id, data }: NodeProps<ConnectionBlockS
|
||||
onSelect={() => handleToolOperationSelect(result.item)}
|
||||
icon={result.item.icon}
|
||||
bgColor={result.item.bgColor}
|
||||
showColoredIcon
|
||||
blockType={result.item.blockType}
|
||||
label={result.item.name}
|
||||
/>
|
||||
)
|
||||
@@ -470,8 +471,7 @@ export function ConnectionBlockSelector({ id, data }: NodeProps<ConnectionBlockS
|
||||
}
|
||||
icon={result.item.icon}
|
||||
bgColor={result.item.bgColor}
|
||||
showColoredIcon
|
||||
workflowType={result.kind === 'block' ? result.item.type : undefined}
|
||||
blockType={result.item.type}
|
||||
label={result.item.name}
|
||||
/>
|
||||
)
|
||||
|
||||
+2
-33
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { ChevronDown, handleKeyboardActivation } from '@sim/emcn'
|
||||
import { Repeat, Split } from '@sim/emcn/icons'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import clsx from 'clsx'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
@@ -12,8 +11,7 @@ import {
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/components/field-item/field-item'
|
||||
import type { ConnectedBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-block-connections'
|
||||
import { useBlockOutputFields } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields'
|
||||
import { getTileIconColorClass } from '@/blocks/icon-color'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { BlockTile } from '@/blocks/block-tile'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { EMPTY_SUBBLOCK_VALUES, useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
@@ -103,8 +101,6 @@ function ConnectionItem({
|
||||
mergedSubBlocks,
|
||||
sourceBlock,
|
||||
}: ConnectionItemProps) {
|
||||
const blockConfig = getBlock(connection.type)
|
||||
|
||||
const fields = useBlockOutputFields({
|
||||
blockId: connection.id,
|
||||
blockType: connection.type,
|
||||
@@ -113,19 +109,6 @@ function ConnectionItem({
|
||||
})
|
||||
const hasFields = fields.length > 0
|
||||
|
||||
let Icon = blockConfig?.icon
|
||||
let bgColor = blockConfig?.bgColor || '#6B7280'
|
||||
|
||||
if (!blockConfig) {
|
||||
if (connection.type === 'loop') {
|
||||
Icon = Repeat
|
||||
bgColor = '#2FB3FF'
|
||||
} else if (connection.type === 'parallel') {
|
||||
Icon = Split
|
||||
bgColor = '#FEE12B'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mb-0.5 last:mb-0' ref={connectionRef}>
|
||||
<div
|
||||
@@ -144,21 +127,7 @@ function ConnectionItem({
|
||||
handleKeyboardActivation(event, () => onToggleExpand(connection.id))
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='[&_img]:!size-full relative flex size-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm'
|
||||
style={{ background: bgColor }}
|
||||
>
|
||||
{Icon && (
|
||||
<Icon
|
||||
className={clsx(
|
||||
'transition-transform duration-200',
|
||||
getTileIconColorClass(bgColor),
|
||||
hasFields && 'group-hover:scale-110',
|
||||
'!size-[9px]'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<BlockTile blockType={connection.type} size='sm' />
|
||||
<span
|
||||
className={clsx(
|
||||
'truncate',
|
||||
|
||||
+10
-48
@@ -11,7 +11,6 @@ import {
|
||||
PopoverSection,
|
||||
usePopoverContext,
|
||||
} from '@sim/emcn'
|
||||
import { Repeat, Split } from '@sim/emcn/icons'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||
@@ -30,7 +29,7 @@ import type {
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/types'
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { getTileIconColorClass } from '@/blocks/icon-color'
|
||||
import { BlockTile } from '@/blocks/block-tile'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { useVariablesStore } from '@/stores/variables/store'
|
||||
@@ -382,25 +381,6 @@ const buildNestedTagTree = (tags: string[], blockName: string): NestedTag[] => {
|
||||
return convertToNestedTags(root, '', blockName)
|
||||
}
|
||||
|
||||
const TagIcon: React.FC<{
|
||||
icon: string | React.ComponentType<{ className?: string }>
|
||||
color: string
|
||||
}> = ({ icon, color }) => (
|
||||
<div
|
||||
className='flex size-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded [&_img]:size-full'
|
||||
style={{ background: color }}
|
||||
>
|
||||
{typeof icon === 'string' ? (
|
||||
<span className={cn(getTileIconColorClass(color, true), 'font-bold text-micro')}>{icon}</span>
|
||||
) : (
|
||||
(() => {
|
||||
const IconComponent = icon
|
||||
return <IconComponent className={cn(getTileIconColorClass(color, true), 'size-[9px]')} />
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
/**
|
||||
* Props for the recursive NestedTagRenderer component
|
||||
*/
|
||||
@@ -811,8 +791,7 @@ const BlockRootTagItem: React.FC<{
|
||||
handleTagSelect: (tag: string, group?: BlockTagGroup) => void
|
||||
itemRefs: React.RefObject<Map<string, HTMLElement>>
|
||||
group: BlockTagGroup
|
||||
tagIcon: string | React.ComponentType<{ className?: string }>
|
||||
blockColor: string
|
||||
blockType: string
|
||||
blockName: string
|
||||
}> = ({
|
||||
rootTag,
|
||||
@@ -822,8 +801,7 @@ const BlockRootTagItem: React.FC<{
|
||||
handleTagSelect,
|
||||
itemRefs,
|
||||
group,
|
||||
tagIcon,
|
||||
blockColor,
|
||||
blockType,
|
||||
blockName,
|
||||
}) => {
|
||||
const handleMouseEnter = useKeyboardAwareMouseEnter(setSelectedIndex)
|
||||
@@ -844,7 +822,11 @@ const BlockRootTagItem: React.FC<{
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TagIcon icon={tagIcon} color={blockColor} />
|
||||
<BlockTile
|
||||
blockType={blockType}
|
||||
fallbackLabel={blockName.charAt(0).toUpperCase()}
|
||||
size='sm'
|
||||
/>
|
||||
<span className='flex-1 truncate'>{blockName}</span>
|
||||
</PopoverItem>
|
||||
)
|
||||
@@ -1727,7 +1709,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
<>
|
||||
<PopoverSection rootOnly>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<TagIcon icon='V' color={BLOCK_COLORS.VARIABLE} />
|
||||
<BlockTile bgColor={BLOCK_COLORS.VARIABLE} fallbackLabel='V' size='sm' />
|
||||
Variables
|
||||
</div>
|
||||
</PopoverSection>
|
||||
@@ -1753,25 +1735,6 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
)}
|
||||
|
||||
{nestedBlockTagGroups.map((group: NestedBlockTagGroup, groupIndex: number) => {
|
||||
const blockConfig = getBlock(group.blockType)
|
||||
let blockColor = blockConfig?.bgColor || BLOCK_COLORS.DEFAULT
|
||||
|
||||
if (group.blockType === 'loop') {
|
||||
blockColor = BLOCK_COLORS.LOOP
|
||||
} else if (group.blockType === 'parallel') {
|
||||
blockColor = BLOCK_COLORS.PARALLEL
|
||||
}
|
||||
|
||||
let tagIcon: string | React.ComponentType<{ className?: string }> =
|
||||
group.blockName.charAt(0).toUpperCase()
|
||||
if (blockConfig?.icon) {
|
||||
tagIcon = blockConfig.icon
|
||||
} else if (group.blockType === 'loop') {
|
||||
tagIcon = Repeat
|
||||
} else if (group.blockType === 'parallel') {
|
||||
tagIcon = Split
|
||||
}
|
||||
|
||||
const normalizedBlockName = normalizeName(group.blockName)
|
||||
const rootTagFromTags = group.tags.find((tag) => tag === normalizedBlockName)
|
||||
const rootTag = rootTagFromTags || normalizedBlockName
|
||||
@@ -1788,8 +1751,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
handleTagSelect={handleTagSelect}
|
||||
itemRefs={itemRefs}
|
||||
group={group}
|
||||
tagIcon={tagIcon}
|
||||
blockColor={blockColor}
|
||||
blockType={group.blockType}
|
||||
blockName={group.blockName}
|
||||
/>
|
||||
{group.nestedTags.map((nestedTag) => {
|
||||
|
||||
+2
-1
@@ -54,6 +54,7 @@ import {
|
||||
isBlockProtected,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils'
|
||||
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||
import { hasBlockAccent } from '@/blocks/accent'
|
||||
import { isLightTileColor } from '@/blocks/icon-color'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { useFolderMap } from '@/hooks/queries/folders'
|
||||
@@ -107,7 +108,7 @@ export function Editor() {
|
||||
const currentBlock = currentBlockId ? currentWorkflow.getBlockById(currentBlockId) : null
|
||||
const blockConfig = currentBlock ? getBlock(currentBlock.type) : null
|
||||
const typeAccent = getWorkflowTypeAccent(currentBlock?.type ?? '')
|
||||
const isIntegration = blockConfig?.category === 'tools'
|
||||
const isIntegration = blockConfig != null && !hasBlockAccent(blockConfig.type)
|
||||
const title = currentBlock?.name || 'Editor'
|
||||
const isBlockNameSearchHighlighted =
|
||||
activeSearchTarget?.targetKind === 'block-name' && activeSearchTarget.blockId === currentBlockId
|
||||
|
||||
+7
-28
@@ -21,7 +21,6 @@ import {
|
||||
Info,
|
||||
} from '@sim/emcn'
|
||||
import { ChevronDown, Search } from '@sim/emcn/icons'
|
||||
import { hasWorkflowTypeRole, WorkflowTypeIcon } from '@sim/workflow-renderer'
|
||||
import clsx from 'clsx'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
@@ -34,6 +33,7 @@ import {
|
||||
import { useToolbarItemInteractions } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/hooks'
|
||||
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
|
||||
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
|
||||
import { BlockTile } from '@/blocks/block-tile'
|
||||
import {
|
||||
buildCustomBlockConfig,
|
||||
CUSTOM_BLOCK_TILE_COLOR,
|
||||
@@ -41,7 +41,6 @@ import {
|
||||
} from '@/blocks/custom/build-config'
|
||||
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
|
||||
import { getCustomBlockIcon } from '@/blocks/custom/custom-block-icon'
|
||||
import { getTileIconColorClass } from '@/blocks/icon-color'
|
||||
import { getCanonicalBlocksByCategory } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { useOrgBrandConfig } from '@/ee/whitelabeling/components/branding-provider'
|
||||
@@ -57,7 +56,6 @@ interface BlockItem {
|
||||
config?: BlockConfig
|
||||
icon?: ComponentType<{ className?: string }>
|
||||
bgColor?: string
|
||||
workflowType?: string
|
||||
docsLink?: string
|
||||
}
|
||||
|
||||
@@ -134,26 +132,12 @@ const ToolbarItem = memo(function ToolbarItem({
|
||||
)}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{item.workflowType && Icon ? (
|
||||
<WorkflowTypeIcon type={item.workflowType} Icon={Icon} data-toolbar-item-icon='' />
|
||||
) : (
|
||||
<div
|
||||
data-toolbar-item-icon=''
|
||||
className='relative flex size-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm [&_img]:size-full'
|
||||
style={{ background: item.bgColor }}
|
||||
>
|
||||
{Icon && (
|
||||
<Icon
|
||||
className={clsx(
|
||||
'transition-transform duration-200',
|
||||
getTileIconColorClass(item.bgColor),
|
||||
'group-hover:scale-110',
|
||||
'size-[10px]'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<BlockTile
|
||||
blockType={item.type}
|
||||
icon={Icon}
|
||||
bgColor={item.bgColor}
|
||||
data-toolbar-item-icon=''
|
||||
/>
|
||||
<span className='min-w-0 flex-1 truncate text-[var(--text-body)]'>{item.name}</span>
|
||||
</div>
|
||||
)
|
||||
@@ -210,7 +194,6 @@ function getTriggers(overlayVersion: number): BlockItem[] {
|
||||
config: trigger,
|
||||
icon: trigger.icon,
|
||||
bgColor: trigger.bgColor,
|
||||
workflowType: hasWorkflowTypeRole(trigger.type) ? trigger.type : undefined,
|
||||
docsLink: trigger.docsLink,
|
||||
}))
|
||||
}
|
||||
@@ -247,7 +230,6 @@ function ensureBlockCaches() {
|
||||
config: block,
|
||||
icon: block.icon,
|
||||
bgColor: block.bgColor,
|
||||
workflowType: block.type,
|
||||
}))
|
||||
|
||||
regularBlockItems.push({
|
||||
@@ -255,7 +237,6 @@ function ensureBlockCaches() {
|
||||
type: LoopTool.type,
|
||||
icon: LoopTool.icon,
|
||||
bgColor: LoopTool.bgColor,
|
||||
workflowType: LoopTool.type,
|
||||
docsLink: LoopTool.docsLink,
|
||||
})
|
||||
|
||||
@@ -264,7 +245,6 @@ function ensureBlockCaches() {
|
||||
type: ParallelTool.type,
|
||||
icon: ParallelTool.icon,
|
||||
bgColor: ParallelTool.bgColor,
|
||||
workflowType: ParallelTool.type,
|
||||
docsLink: ParallelTool.docsLink,
|
||||
})
|
||||
|
||||
@@ -274,7 +254,6 @@ function ensureBlockCaches() {
|
||||
config: block,
|
||||
icon: block.icon,
|
||||
bgColor: block.bgColor,
|
||||
workflowType: hasWorkflowTypeRole(block.type) ? block.type : undefined,
|
||||
}))
|
||||
|
||||
regularBlockItems.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
+7
-19
@@ -1,35 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import { chipIconSlotClass, cn } from '@sim/emcn'
|
||||
import { WorkflowTypeIcon } from '@sim/workflow-renderer'
|
||||
import {
|
||||
getBlockColor,
|
||||
getBlockIcon,
|
||||
getEntryAccentType,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils'
|
||||
import { getTileIconColorClass } from '@/blocks/icon-color'
|
||||
import { BlockTile } from '@/blocks/block-tile'
|
||||
|
||||
export interface EntryBlockTileProps {
|
||||
blockType: string
|
||||
}
|
||||
|
||||
/** A log row's block tile. @see getEntryAccentType */
|
||||
/** A log row's block tile. @see BlockTile */
|
||||
export const EntryBlockTile = memo(function EntryBlockTile({ blockType }: EntryBlockTileProps) {
|
||||
const BlockIcon = getBlockIcon(blockType)
|
||||
const bgColor = getBlockColor(blockType)
|
||||
const accentType = getEntryAccentType(blockType)
|
||||
|
||||
if (BlockIcon && accentType) {
|
||||
return <WorkflowTypeIcon type={accentType} Icon={BlockIcon} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(chipIconSlotClass, 'overflow-hidden rounded-md [&_img]:size-full')}
|
||||
style={{ background: bgColor }}
|
||||
>
|
||||
{BlockIcon && <BlockIcon className={cn('size-[10px]', getTileIconColorClass(bgColor))} />}
|
||||
</div>
|
||||
<BlockTile
|
||||
blockType={blockType}
|
||||
icon={getBlockIcon(blockType) ?? undefined}
|
||||
bgColor={getBlockColor(blockType)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
+1
-49
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/blocks', () => ({
|
||||
getBlock: vi.fn().mockReturnValue(null),
|
||||
@@ -17,13 +17,11 @@ vi.mock('@/stores/constants', () => ({
|
||||
TERMINAL_BLOCK_COLUMN_WIDTH: { MIN: 120, DEFAULT: 200, MAX: 400 },
|
||||
}))
|
||||
|
||||
import { getBlock } from '@/blocks'
|
||||
import type { ConsoleEntry } from '@/stores/terminal'
|
||||
import {
|
||||
buildEntryTree,
|
||||
type EntryNode,
|
||||
flattenVisibleExecutionRows,
|
||||
getEntryAccentType,
|
||||
groupEntriesByExecution,
|
||||
} from './utils'
|
||||
|
||||
@@ -858,49 +856,3 @@ describe('flattenVisibleExecutionRows', () => {
|
||||
expect(rowsExpanded[1].depth).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEntryAccentType', () => {
|
||||
const mockedGetBlock = vi.mocked(getBlock)
|
||||
|
||||
afterEach(() => {
|
||||
mockedGetBlock.mockReturnValue(null as never)
|
||||
})
|
||||
|
||||
function withCategory(category: string) {
|
||||
mockedGetBlock.mockReturnValue({ category } as never)
|
||||
}
|
||||
|
||||
it('accents a core block by its type, mapped or not', () => {
|
||||
withCategory('blocks')
|
||||
expect(getEntryAccentType('agent')).toBe('agent')
|
||||
/*
|
||||
* An unmapped core block still takes the accent path and lands on `neutral`,
|
||||
* which is exactly what the block toolbar does for a newly added one — the
|
||||
* two surfaces must not disagree while the role map catches up.
|
||||
*/
|
||||
expect(getEntryAccentType('brand_new_core_block')).toBe('brand_new_core_block')
|
||||
})
|
||||
|
||||
it('accents a non-core block only when it carries a canvas role', () => {
|
||||
withCategory('tools')
|
||||
expect(getEntryAccentType('table')).toBe('table')
|
||||
// A role-less integration keeps its own provider colour instead.
|
||||
expect(getEntryAccentType('gmail')).toBeUndefined()
|
||||
|
||||
withCategory('triggers')
|
||||
expect(getEntryAccentType('schedule')).toBe('schedule')
|
||||
expect(getEntryAccentType('some_vendor_trigger')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('accents subflows, which carry a role but no registry config', () => {
|
||||
expect(getEntryAccentType('loop')).toBe('loop')
|
||||
expect(getEntryAccentType('parallel')).toBe('parallel')
|
||||
expect(getEntryAccentType('workflow')).toBe('workflow')
|
||||
})
|
||||
|
||||
it('leaves the terminal-synthesized run rows on their own status fill', () => {
|
||||
expect(getEntryAccentType('error')).toBeUndefined()
|
||||
expect(getEntryAccentType('validation')).toBeUndefined()
|
||||
expect(getEntryAccentType('cancelled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type React from 'react'
|
||||
import { Ban, CircleX, Repeat, Split, TriangleAlert, Workflow } from '@sim/emcn/icons'
|
||||
import { hasWorkflowTypeRole } from '@sim/workflow-renderer'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { isWorkflowBlockType } from '@/executor/constants'
|
||||
import { TERMINAL_BLOCK_COLUMN_WIDTH } from '@/stores/constants'
|
||||
@@ -94,23 +93,6 @@ export function getBlockColor(blockType: string): string {
|
||||
return '#6b7280'
|
||||
}
|
||||
|
||||
/**
|
||||
* The type a log row's tile takes its accent from, or `undefined` when the row
|
||||
* must fall back to the block's own provider colour.
|
||||
*
|
||||
* Same rule the block toolbar applies, so a block is accented identically
|
||||
* wherever it is listed: a core block always takes the canvas role accent (an
|
||||
* unmapped one lands on `neutral`, exactly as it does in the toolbar), and
|
||||
* anything else — integrations, triggers, subflows — takes one only if it has a
|
||||
* role. That second clause is what leaves the terminal's synthesized
|
||||
* `error`/`validation`/`cancelled` rows on their own status fill: they carry no
|
||||
* config and no role, so they fall through to the provider-colour branch.
|
||||
*/
|
||||
export function getEntryAccentType(blockType: string): string | undefined {
|
||||
const isCoreBlock = getBlock(blockType)?.category === 'blocks'
|
||||
return isCoreBlock || hasWorkflowTypeRole(blockType) ? blockType : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a keyboard event originated from a text-editable element
|
||||
*/
|
||||
|
||||
+2
-1
@@ -84,6 +84,7 @@ import {
|
||||
useIsBlockInActiveExecutionHandoff,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { useBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
|
||||
import { hasBlockAccent } from '@/blocks/accent'
|
||||
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import {
|
||||
@@ -1219,7 +1220,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
isExecutionHighlighted={isExecutionHighlighted}
|
||||
Icon={config.icon}
|
||||
iconBgColor={config.bgColor}
|
||||
isIntegration={config.category === 'tools'}
|
||||
isIntegration={!hasBlockAccent(config.type)}
|
||||
horizontalHandles={horizontalHandles}
|
||||
shouldShowDefaultHandles={shouldShowDefaultHandles}
|
||||
blockHeight={blockHeight}
|
||||
|
||||
+2
-18
@@ -45,6 +45,7 @@ import { PreviewContextMenu } from '@/app/workspace/[workspaceId]/w/components/p
|
||||
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { BlockTile } from '@/blocks/block-tile'
|
||||
import { getTileIconColorClass } from '@/blocks/icon-color'
|
||||
import type { BlockConfig, BlockIcon, SubBlockConfig, SubBlockType } from '@/blocks/types'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
@@ -356,9 +357,6 @@ function ConnectionsSection({
|
||||
{/* Content - styled like ConnectionBlocks */}
|
||||
<div className='flex-1 space-y-0.5 overflow-y-auto overflow-x-hidden px-1.5 pb-2'>
|
||||
{connections.map((connection) => {
|
||||
const blockConfig = getBlock(connection.blockType)
|
||||
const Icon = blockConfig?.icon
|
||||
const bgColor = blockConfig?.bgColor || '#6B7280'
|
||||
const isExpanded = expandedBlocks.has(connection.blockId)
|
||||
const hasFields = connection.fields.length > 0
|
||||
|
||||
@@ -379,21 +377,7 @@ function ConnectionsSection({
|
||||
handleKeyboardActivation(event, () => toggleBlock(connection.blockId))
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='relative flex size-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm'
|
||||
style={{ background: bgColor }}
|
||||
>
|
||||
{Icon && (
|
||||
<Icon
|
||||
className={cn(
|
||||
'transition-transform duration-200',
|
||||
getTileIconColorClass(bgColor),
|
||||
hasFields && 'group-hover:scale-110',
|
||||
'!h-[9px] !w-[9px]'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<BlockTile blockType={connection.blockType} size='sm' />
|
||||
<span
|
||||
className={cn(
|
||||
'truncate',
|
||||
|
||||
+2
-1
@@ -35,6 +35,7 @@ import {
|
||||
isToolInputOnlySubBlock,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { hasBlockAccent } from '@/blocks/accent'
|
||||
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
|
||||
import { useVariablesStore } from '@/stores/variables/store'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
@@ -522,7 +523,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
typeLabel={canvasPresentation.typeLabel}
|
||||
Icon={IconComponent}
|
||||
iconBgColor={blockConfig.bgColor}
|
||||
isIntegration={blockConfig.category === 'tools'}
|
||||
isIntegration={!hasBlockAccent(type)}
|
||||
isEnabled={enabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
+4
-24
@@ -2,14 +2,12 @@
|
||||
|
||||
import type { ComponentType } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { File, Workflow } from '@sim/emcn/icons'
|
||||
import { WorkflowTypeIcon } from '@sim/workflow-renderer'
|
||||
import { Command } from 'cmdk'
|
||||
import { HEX_COLOR_REGEX } from '@/lib/branding'
|
||||
import type { CommandItemProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
|
||||
import { COMMAND_ITEM_CLASSNAME } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
|
||||
import { getTileIconColorClass } from '@/blocks/icon-color'
|
||||
import { BlockTile } from '@/blocks/block-tile'
|
||||
|
||||
interface ResultMetaProps {
|
||||
meta?: string
|
||||
@@ -85,31 +83,14 @@ export const MemoizedCommandItem = memo(
|
||||
onSelect,
|
||||
icon: Icon,
|
||||
bgColor,
|
||||
showColoredIcon,
|
||||
workflowType,
|
||||
blockType,
|
||||
label,
|
||||
labelPrefix,
|
||||
meta,
|
||||
}: CommandItemProps) {
|
||||
return (
|
||||
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
|
||||
{workflowType ? (
|
||||
<WorkflowTypeIcon type={workflowType} Icon={Icon} />
|
||||
) : (
|
||||
<div
|
||||
className='relative flex size-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm [&_img]:size-full'
|
||||
style={{ background: showColoredIcon ? bgColor : 'transparent' }}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
'transition-transform duration-100 group-hover:scale-110',
|
||||
showColoredIcon
|
||||
? `size-[10px] ${getTileIconColorClass(bgColor)}`
|
||||
: 'size-[16px] text-[var(--text-icon)]'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<BlockTile blockType={blockType} icon={Icon} bgColor={bgColor} />
|
||||
<span className='truncate text-[var(--text-body)]'>
|
||||
{labelPrefix && <span className='text-[var(--text-subtle)]'>{labelPrefix} </span>}
|
||||
{label}
|
||||
@@ -122,8 +103,7 @@ export const MemoizedCommandItem = memo(
|
||||
prev.value === next.value &&
|
||||
prev.icon === next.icon &&
|
||||
prev.bgColor === next.bgColor &&
|
||||
prev.showColoredIcon === next.showColoredIcon &&
|
||||
prev.workflowType === next.workflowType &&
|
||||
prev.blockType === next.blockType &&
|
||||
prev.label === next.label &&
|
||||
prev.labelPrefix === next.labelPrefix &&
|
||||
prev.meta === next.meta
|
||||
|
||||
+6
-10
@@ -42,8 +42,7 @@ export const BlocksGroup = memo(function BlocksGroup({
|
||||
onSelect={() => onSelect(block)}
|
||||
icon={block.icon}
|
||||
bgColor={block.bgColor}
|
||||
showColoredIcon
|
||||
workflowType={block.type}
|
||||
blockType={block.type}
|
||||
label={block.name}
|
||||
/>
|
||||
))}
|
||||
@@ -69,7 +68,7 @@ export const ToolsGroup = memo(function ToolsGroup({
|
||||
onSelect={() => onSelect(tool)}
|
||||
icon={tool.icon}
|
||||
bgColor={tool.bgColor}
|
||||
showColoredIcon
|
||||
blockType={tool.type}
|
||||
label={tool.name}
|
||||
/>
|
||||
))}
|
||||
@@ -108,8 +107,7 @@ function renderSearchEntry(
|
||||
onSelect={() => handlers.onSelectBlock(entry.item)}
|
||||
icon={entry.item.icon}
|
||||
bgColor={entry.item.bgColor}
|
||||
showColoredIcon
|
||||
workflowType={entry.item.type}
|
||||
blockType={entry.item.type}
|
||||
label={entry.item.name}
|
||||
/>
|
||||
)
|
||||
@@ -121,7 +119,7 @@ function renderSearchEntry(
|
||||
onSelect={() => handlers.onSelectTool(entry.item)}
|
||||
icon={entry.item.icon}
|
||||
bgColor={entry.item.bgColor}
|
||||
showColoredIcon
|
||||
blockType={entry.item.type}
|
||||
label={entry.item.name}
|
||||
/>
|
||||
)
|
||||
@@ -133,7 +131,7 @@ function renderSearchEntry(
|
||||
onSelect={() => handlers.onSelectTrigger(entry.item)}
|
||||
icon={entry.item.icon}
|
||||
bgColor={entry.item.bgColor}
|
||||
showColoredIcon
|
||||
blockType={entry.item.type}
|
||||
label={entry.item.name}
|
||||
/>
|
||||
)
|
||||
@@ -145,7 +143,7 @@ function renderSearchEntry(
|
||||
onSelect={() => handlers.onSelectToolOperation(entry.item)}
|
||||
icon={entry.item.icon}
|
||||
bgColor={entry.item.bgColor}
|
||||
showColoredIcon
|
||||
blockType={entry.item.blockType}
|
||||
labelPrefix={entry.item.serviceName}
|
||||
label={entry.item.name}
|
||||
/>
|
||||
@@ -158,7 +156,6 @@ function renderSearchEntry(
|
||||
onSelect={() => handlers.onSelectConnectedAccount(entry.item)}
|
||||
icon={entry.item.icon}
|
||||
bgColor={entry.item.bgColor}
|
||||
showColoredIcon
|
||||
label={entry.item.name}
|
||||
/>
|
||||
)
|
||||
@@ -170,7 +167,6 @@ function renderSearchEntry(
|
||||
onSelect={() => handlers.onSelectIntegration(entry.item)}
|
||||
icon={entry.item.icon}
|
||||
bgColor={entry.item.bgColor}
|
||||
showColoredIcon
|
||||
label={entry.item.name}
|
||||
/>
|
||||
)
|
||||
|
||||
+48
@@ -10,6 +10,7 @@ import {
|
||||
type MothershipSendMessageDetail,
|
||||
} from '@/lib/mothership/events'
|
||||
import { SearchModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
|
||||
const { mockPush, mockSearchState } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
@@ -691,6 +692,53 @@ describe('SearchModal', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('accents a first-party trigger by its canvas role, not its catalog color', async () => {
|
||||
const Icon = () => null
|
||||
const original = { ...mockSearchState.data }
|
||||
/*
|
||||
* The palette reads the accent from the block's category, so the row's
|
||||
* appearance is only meaningful against a registry that reports one — the
|
||||
* shared mock omits it.
|
||||
*/
|
||||
const mockedGetBlock = vi.mocked(getBlock)
|
||||
const originalGetBlock = mockedGetBlock.getMockImplementation()
|
||||
mockedGetBlock.mockImplementation(
|
||||
(type: string) => ({ category: type === 'slack' ? 'tools' : 'triggers', icon: Icon }) as never
|
||||
)
|
||||
mockSearchState.data = {
|
||||
...mockSearchState.data,
|
||||
triggers: [
|
||||
{
|
||||
id: 'generic_webhook',
|
||||
name: 'Webhook Trigger',
|
||||
icon: Icon,
|
||||
bgColor: '#10B981',
|
||||
type: 'generic_webhook',
|
||||
},
|
||||
{
|
||||
id: 'slack',
|
||||
name: 'Slack',
|
||||
icon: Icon,
|
||||
bgColor: '#611f69',
|
||||
type: 'slack',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(<SearchModal open onOpenChange={vi.fn()} pageContext='workflow' />)
|
||||
})
|
||||
|
||||
expect(document.querySelector('[data-workflow-type-icon="generic_webhook"]')).not.toBeNull()
|
||||
// A third-party trigger keeps its brand tile, exactly as the canvas paints it.
|
||||
expect(document.querySelector('[data-workflow-type-icon="slack"]')).toBeNull()
|
||||
} finally {
|
||||
mockSearchState.data = original
|
||||
if (originalGetBlock) mockedGetBlock.mockImplementation(originalGetBlock)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the palette open when the query handoff cannot be persisted', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
const storeSpy = vi.spyOn(MothershipHandoffStorage, 'store').mockReturnValue(false)
|
||||
|
||||
+3
-5
@@ -186,13 +186,11 @@ export interface CommandItemProps {
|
||||
onSelect: () => void
|
||||
icon: ComponentType<{ className?: string }>
|
||||
bgColor: string
|
||||
showColoredIcon?: boolean
|
||||
/**
|
||||
* Core workflow block type. Renders as the shared accent chip only when the
|
||||
* type has a mapped accent; unmapped types — every integration block — fall
|
||||
* back to their catalog `bgColor` tile.
|
||||
* Block the row names, when it names one. Decides whether the tile takes the
|
||||
* canvas role accent or the row's own `bgColor`. @see hasBlockAccent
|
||||
*/
|
||||
workflowType?: string
|
||||
blockType?: string
|
||||
/** Primary text of the row. */
|
||||
label: string
|
||||
/** De-emphasized lead-in before the label (e.g. a tool operation's service). */
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/blocks/registry', () => ({
|
||||
getBlock: vi.fn().mockReturnValue(null),
|
||||
}))
|
||||
|
||||
import { hasBlockAccent } from '@/blocks/accent'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
|
||||
describe('hasBlockAccent', () => {
|
||||
const mockedGetBlock = vi.mocked(getBlock)
|
||||
|
||||
afterEach(() => {
|
||||
mockedGetBlock.mockReturnValue(null as never)
|
||||
})
|
||||
|
||||
function withCategory(category: string) {
|
||||
mockedGetBlock.mockReturnValue({ category } as never)
|
||||
}
|
||||
|
||||
it('accents a core block, mapped or not', () => {
|
||||
withCategory('blocks')
|
||||
expect(hasBlockAccent('agent')).toBe(true)
|
||||
/*
|
||||
* An unmapped core block still takes the accent and lands on `neutral`,
|
||||
* which is what the canvas does for a newly added one — the surfaces must
|
||||
* not disagree while the role map catches up.
|
||||
*/
|
||||
expect(hasBlockAccent('brand_new_core_block')).toBe(true)
|
||||
})
|
||||
|
||||
it('accents every first-party trigger, role or no role', () => {
|
||||
withCategory('triggers')
|
||||
// Catalogued green, but an `interface` block on the canvas.
|
||||
expect(hasBlockAccent('generic_webhook')).toBe(true)
|
||||
expect(hasBlockAccent('schedule')).toBe(true)
|
||||
/*
|
||||
* Roleless triggers are the same bug one step further out: the canvas
|
||||
* paints them `neutral`, so a list keyed off the role map alone would have
|
||||
* put them back on the catalog blues this rule exists to overrule.
|
||||
*/
|
||||
expect(hasBlockAccent('manual_trigger')).toBe(true)
|
||||
expect(hasBlockAccent('circleback')).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves a third-party integration on its brand, even if it gains a role', () => {
|
||||
withCategory('tools')
|
||||
expect(hasBlockAccent('gmail')).toBe(false)
|
||||
/*
|
||||
* Role membership must not override the category. The canvas brands every
|
||||
* `tools` block, so accenting one here would reintroduce the mismatch in
|
||||
* the opposite direction.
|
||||
*/
|
||||
expect(hasBlockAccent('table')).toBe(false)
|
||||
})
|
||||
|
||||
it('accents configless subflows, which carry a role', () => {
|
||||
expect(hasBlockAccent('loop')).toBe(true)
|
||||
expect(hasBlockAccent('parallel')).toBe(true)
|
||||
expect(hasBlockAccent('workflow')).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves the terminal-synthesized run rows on their own status fill', () => {
|
||||
expect(hasBlockAccent('error')).toBe(false)
|
||||
expect(hasBlockAccent('validation')).toBe(false)
|
||||
expect(hasBlockAccent('cancelled')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import { Repeat, Split } from '@sim/emcn/icons'
|
||||
import { hasWorkflowTypeRole } from '@sim/workflow-renderer'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
|
||||
/** Tile fill for a block that has no config of its own to colour it. */
|
||||
export const DEFAULT_BLOCK_TILE_COLOR = '#6B7280'
|
||||
|
||||
/**
|
||||
* Subflow tiles. Loop and Parallel are canvas blocks with no registry config,
|
||||
* so every surface that lists them had to special-case the pair; they resolve
|
||||
* here instead.
|
||||
*/
|
||||
const SUBFLOW_ICONS: Record<string, ComponentType<{ className?: string }>> = {
|
||||
loop: Repeat,
|
||||
parallel: Split,
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a block's tile takes the canvas role accent rather than the block's
|
||||
* own provider colour. This is the canvas's own rule, read from one place so
|
||||
* every surface that lists a block agrees with the card it names: only a
|
||||
* third-party integration wears its brand, everything first-party wears its
|
||||
* role accent — landing on `neutral` when the role map has no entry yet,
|
||||
* exactly as the canvas does.
|
||||
*
|
||||
* Keying off the catalog `bgColor` instead is what made a palette row
|
||||
* contradict the card it was about to place: `generic_webhook` is catalogued
|
||||
* green but is an `interface` block on the canvas, and the five roleless
|
||||
* first-party triggers (`api_trigger`, `chat_trigger`, `circleback`,
|
||||
* `input_trigger`, `manual_trigger`) are neutral there while their configs
|
||||
* carry blues and a gradient.
|
||||
*
|
||||
* A type with no config at all falls back to role membership, which accents the
|
||||
* subflows (`loop`, `parallel`, `workflow`) and leaves the terminal's
|
||||
* synthesized `error`/`validation`/`cancelled` rows on their status fill.
|
||||
*/
|
||||
export function hasBlockAccent(blockType: string): boolean {
|
||||
const config = getBlock(blockType)
|
||||
return config ? config.category !== 'tools' : hasWorkflowTypeRole(blockType)
|
||||
}
|
||||
|
||||
/** A block's tile icon, including the subflows that carry no registry config. */
|
||||
export function getBlockTileIcon(
|
||||
blockType: string
|
||||
): ComponentType<{ className?: string }> | undefined {
|
||||
return getBlock(blockType)?.icon ?? SUBFLOW_ICONS[blockType]
|
||||
}
|
||||
|
||||
/** A block's provider tile fill. Only reached when it takes no accent. */
|
||||
export function getBlockTileColor(blockType: string): string {
|
||||
return getBlock(blockType)?.bgColor || DEFAULT_BLOCK_TILE_COLOR
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import type { ComponentType, HTMLAttributes } from 'react'
|
||||
import { chipIconSlotClass, cn } from '@sim/emcn'
|
||||
import { WorkflowTypeIcon } from '@sim/workflow-renderer'
|
||||
import { getBlockTileColor, getBlockTileIcon, hasBlockAccent } from '@/blocks/accent'
|
||||
import { getTileIconColorClass } from '@/blocks/icon-color'
|
||||
|
||||
/** Slot sizes the tile ships in: the canvas 16px chip, or 14px for dense rows. */
|
||||
const TILE_SIZE_CLASS = {
|
||||
md: 'size-[16px]',
|
||||
sm: 'size-[14px]',
|
||||
} as const
|
||||
|
||||
export interface BlockTileProps extends Omit<HTMLAttributes<HTMLElement>, 'children' | 'style'> {
|
||||
/**
|
||||
* Block the tile represents; decides whether it takes the canvas role accent.
|
||||
* Omitted by rows that name no block — a catalog integration, a section
|
||||
* header — which keep their own fill. @see hasBlockAccent
|
||||
*/
|
||||
blockType?: string
|
||||
/** Defaults to the block's registered icon. */
|
||||
icon?: ComponentType<{ className?: string }>
|
||||
/** Provider fill, used only when the block takes no accent. */
|
||||
bgColor?: string
|
||||
/** Drawn on the provider tile when there is no icon — usually a name initial. */
|
||||
fallbackLabel?: string
|
||||
size?: keyof typeof TILE_SIZE_CLASS
|
||||
}
|
||||
|
||||
/**
|
||||
* The one block tile. Renders the shared canvas accent chip for anything that
|
||||
* carries a role and the block's provider tile for everything else, so a block
|
||||
* reads the same in every list it appears in. @see hasBlockAccent
|
||||
*
|
||||
* The tile owns its icon colour outright, which is why the contrast class is
|
||||
* always the `!important` variant: these rows live inside popover, combobox and
|
||||
* command surfaces that paint descendants through `[&_svg]:text-*`, and a plain
|
||||
* utility on the icon loses to that parent rule — pale brand tiles would render
|
||||
* their icon white-on-white.
|
||||
*
|
||||
* Reaches the block registry, so it is for workspace surfaces only. Public
|
||||
* marketing pages keep their own tile rather than pull every block config into
|
||||
* that bundle — see `@/blocks/icon-color`.
|
||||
*/
|
||||
export function BlockTile({
|
||||
blockType,
|
||||
icon,
|
||||
bgColor,
|
||||
fallbackLabel,
|
||||
size = 'md',
|
||||
className,
|
||||
...props
|
||||
}: BlockTileProps) {
|
||||
const Icon = icon ?? (blockType ? getBlockTileIcon(blockType) : undefined)
|
||||
const sizeClass = cn(TILE_SIZE_CLASS[size], className)
|
||||
|
||||
if (blockType && Icon && hasBlockAccent(blockType)) {
|
||||
return <WorkflowTypeIcon type={blockType} Icon={Icon} className={sizeClass} {...props} />
|
||||
}
|
||||
|
||||
const fill = bgColor ?? (blockType ? getBlockTileColor(blockType) : undefined)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(chipIconSlotClass, 'overflow-hidden rounded-md [&_img]:size-full', sizeClass)}
|
||||
style={{ background: fill }}
|
||||
{...props}
|
||||
>
|
||||
{Icon ? (
|
||||
<Icon
|
||||
className={cn(
|
||||
'size-[10px] transition-transform duration-100 group-hover:scale-110',
|
||||
getTileIconColorClass(fill, true)
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
fallbackLabel && (
|
||||
<span className={cn('font-bold text-micro', getTileIconColorClass(fill, true))}>
|
||||
{fallbackLabel}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user