onToggleExpand(connection.id))
}}
>
-
- {Icon && (
-
- )}
-
+
{
return convertToNestedTags(root, '', blockName)
}
-const TagIcon: React.FC<{
- icon: string | React.ComponentType<{ className?: string }>
- color: string
-}> = ({ icon, color }) => (
-
- {typeof icon === 'string' ? (
- {icon}
- ) : (
- (() => {
- const IconComponent = icon
- return
- })()
- )}
-
-)
-
/**
* Props for the recursive NestedTagRenderer component
*/
@@ -811,8 +791,7 @@ const BlockRootTagItem: React.FC<{
handleTagSelect: (tag: string, group?: BlockTagGroup) => void
itemRefs: React.RefObject
)
@@ -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))
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/entry-block-tile/entry-block-tile.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/entry-block-tile/entry-block-tile.tsx
index b0ffdf95f69..88ada367e9a 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/entry-block-tile/entry-block-tile.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/entry-block-tile/entry-block-tile.tsx
@@ -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
- }
-
return (
-
- {BlockIcon && }
-
+
)
})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.test.ts
index e1b8c5f0f72..ee07dba42dd 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.test.ts
@@ -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()
- })
-})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts
index d1b5aa1f07d..c8c04655e6d 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts
@@ -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
*/
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx
index 889eb2aff47..ad3f911e2fd 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx
@@ -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}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx
index c359228792f..6a852272a8d 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx
@@ -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 */}
{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))
}}
>
-
- {Icon && (
-
- )}
-
+
typeLabel={canvasPresentation.typeLabel}
Icon={IconComponent}
iconBgColor={blockConfig.bgColor}
- isIntegration={blockConfig.category === 'tools'}
+ isIntegration={!hasBlockAccent(type)}
isEnabled={enabled}
/>
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx
index 5e53d16e56d..c67b2089917 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx
@@ -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 (
- {workflowType ? (
-
- ) : (
-
-
-
- )}
+
{labelPrefix && {labelPrefix} }
{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
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx
index 2ff8c4cdaeb..5277dd70a84 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx
@@ -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}
/>
)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx
index e2baf2296b0..e757551e688 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx
@@ -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()
+ })
+
+ 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)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts
index 00f32405d08..7403ae52c85 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts
@@ -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). */
diff --git a/apps/sim/blocks/accent.test.ts b/apps/sim/blocks/accent.test.ts
new file mode 100644
index 00000000000..4d8f12fb2f1
--- /dev/null
+++ b/apps/sim/blocks/accent.test.ts
@@ -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)
+ })
+})
diff --git a/apps/sim/blocks/accent.ts b/apps/sim/blocks/accent.ts
new file mode 100644
index 00000000000..f9ad1101a0d
--- /dev/null
+++ b/apps/sim/blocks/accent.ts
@@ -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> = {
+ 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
+}
diff --git a/apps/sim/blocks/block-tile.tsx b/apps/sim/blocks/block-tile.tsx
new file mode 100644
index 00000000000..c43c77ac94b
--- /dev/null
+++ b/apps/sim/blocks/block-tile.tsx
@@ -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, '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
+ }
+
+ const fill = bgColor ?? (blockType ? getBlockTileColor(blockType) : undefined)
+
+ return (
+
+ {Icon ? (
+
+ ) : (
+ fallbackLabel && (
+
+ {fallbackLabel}
+
+ )
+ )}
+
+ )
+}