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:
Waleed
2026-08-12 11:43:38 -07:00
committed by GitHub
parent 7022e2ef4f
commit 8a0b32862e
23 changed files with 339 additions and 367 deletions
@@ -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
@@ -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'>
@@ -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>
),
@@ -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>
),
@@ -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,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',
@@ -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) => {
@@ -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
@@ -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))
@@ -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,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
*/
@@ -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}
@@ -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',
@@ -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}
/>
)}
@@ -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
@@ -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}
/>
)
@@ -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)
@@ -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). */
+71
View File
@@ -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)
})
})
+53
View File
@@ -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
}
+86
View File
@@ -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>
)
}