diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx index a5bd8cdf51a..96b2c2f8e3d 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx @@ -19,6 +19,7 @@ import { useSession } from '@/lib/auth/auth-client' import type { OAuthReturnContext } from '@/lib/credentials/client-state' import { ADD_CONNECTOR_SEARCH_PARAM, writeOAuthReturnContext } from '@/lib/credentials/client-state' import { defaultCredentialDisplayName } from '@/lib/credentials/display-name' +import { resolveIntegrationBlockTypeForOAuth } from '@/lib/integrations' import { getProviderIdFromServiceId, OAUTH_PROVIDERS, @@ -26,6 +27,7 @@ import { parseProvider, } from '@/lib/oauth' import { getScopeDescription, getServiceConfigByProviderId } from '@/lib/oauth/utils' +import { BlockTile } from '@/blocks/block-tile' import { useCreateCredentialDraft, useWorkspaceCredentials } from '@/hooks/queries/credentials' import { useConnectOAuthService } from '@/hooks/queries/oauth/oauth-connections' @@ -173,6 +175,20 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { return resolveService(provider, props.serviceId ?? providerId) }, [props.serviceName, props.serviceIcon, props.provider, props.serviceId, providerId]) + /** + * The block behind this OAuth identity, so the dialog wears the same brand + * tile the canvas and the integrations catalog do. Falls back to the bare + * `OAUTH_PROVIDERS` mark for an id no catalog integration claims. + */ + const headerIcon = useMemo(() => { + const blockType = resolveIntegrationBlockTypeForOAuth( + props.serviceId, + props.provider, + providerId + ) + return blockType ? : ProviderIcon + }, [props.serviceId, props.provider, providerId, ProviderIcon]) + const workspaceId = isConnect ? props.workspaceId : '' const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({ workspaceId, @@ -343,7 +359,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { return ( - + {title} diff --git a/apps/sim/app/workspace/[workspaceId]/components/provider-icon/index.ts b/apps/sim/app/workspace/[workspaceId]/components/provider-icon/index.ts new file mode 100644 index 00000000000..f3206a41318 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/provider-icon/index.ts @@ -0,0 +1 @@ +export { ProviderIcon } from './provider-icon' diff --git a/apps/sim/app/workspace/[workspaceId]/components/provider-icon/provider-icon.tsx b/apps/sim/app/workspace/[workspaceId]/components/provider-icon/provider-icon.tsx new file mode 100644 index 00000000000..dd5f3bbdd55 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/provider-icon/provider-icon.tsx @@ -0,0 +1,35 @@ +'use client' + +import { cn } from '@sim/emcn' +import { SquareArrowUpRight } from '@sim/emcn/icons' +import { OAUTH_PROVIDERS, type OAuthProvider, parseProvider } from '@/lib/oauth' +import { getBareIconStyle, type StyleableIcon } from '@/blocks/brand-icon-style' + +interface ProviderIconProps { + provider: OAuthProvider + className?: string +} + +/** + * The mark for an OAuth provider, tinted with the brand colour its block + * config registers. Credential rows show a bare icon rather than the filled + * tile the canvas uses, so the colour has to come through `iconColor` — but it + * still comes from the same registry, which is what keeps a provider looking + * like itself everywhere it is listed. + * + * `OAUTH_PROVIDERS` carries the icon and no colour at all, so rendering + * straight from it is what left credential surfaces grey while the same + * service was branded a panel away. Falls back to a generic mark for a + * provider that map does not know. + */ +export function ProviderIcon({ provider, className }: ProviderIconProps) { + const { baseProvider } = parseProvider(provider) + const config = OAUTH_PROVIDERS[baseProvider] + + if (!config) return + + const Icon = config.icon as StyleableIcon + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx index 6f8653d1611..8dd8d3aad39 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx @@ -258,7 +258,6 @@ export function useAvailableResources( id: integration.blockType, name: integration.name, iconComponent: integration.icon, - bgColor: integration.bgColor, })), }, { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx index 8641b9b5eab..49f0be5e844 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx @@ -7,7 +7,6 @@ import { randomFloat } from '@sim/utils/random' import { stripVersionSuffix } from '@sim/utils/string' import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' -import { GmailIcon, SlackIcon } from '@/components/icons' import { INTEGRATIONS, type OAuthServiceMatch, @@ -16,6 +15,7 @@ import { } from '@/lib/integrations' import { captureEvent } from '@/lib/posthog/client' import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' +import { getBlockTileIcon } from '@/blocks/accent' import { getBareIconStyle } from '@/blocks/brand-icon-style' import { getAllBlockMeta } from '@/blocks/registry' import type { ModuleTag } from '@/blocks/types' @@ -224,6 +224,16 @@ function computeActions(services: readonly ServiceInfo[], signals: Signals): Act return [...integrations, ...prompts] } +/** + * Integrations pinned to the first paint. Named by block type so the mark comes + * from the same registry every other surface reads, rather than a second copy + * imported here that could drift from the block's own icon. + */ +const INITIAL_INTEGRATIONS = [ + { blockType: 'slack', slug: 'slack', name: 'Slack' }, + { blockType: 'gmail', slug: 'gmail', name: 'Gmail' }, +] as const + /** * Initial actions rendered on first paint, before OAuth/credentials queries * resolve. For users with no connections this is also the final result, so the @@ -231,20 +241,20 @@ function computeActions(services: readonly ServiceInfo[], signals: Signals): Act * before the personalized recompute replaces it. */ const INITIAL_ACTIONS: Action[] = [ - { - kind: 'integration', - id: 'integrate-slack', - label: 'Integrate with Slack', - icon: SlackIcon, - slug: 'slack', - }, - { - kind: 'integration', - id: 'integrate-gmail', - label: 'Integrate with Gmail', - icon: GmailIcon, - slug: 'gmail', - }, + ...INITIAL_INTEGRATIONS.flatMap(({ blockType, slug, name }) => { + const icon = getBlockTileIcon(blockType) + return icon + ? [ + { + kind: 'integration', + id: `integrate-${slug}`, + label: `Integrate with ${name}`, + icon, + slug, + }, + ] + : [] + }), toPromptAction(TABLE_STARTERS[0]), ...CANDIDATES.filter((c) => c.blockType === 'github' && c.featured) .slice(0, 1) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index f8fbafb5e47..2a2cbf9dd23 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -8,14 +8,12 @@ import { useQueryState } from 'nuqs' import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar' import { isChatEnabled } from '@/lib/core/config/env-flags' import { - blockTypeToIconMap, type Integration, resolveCredentialDisplay, resolveOAuthServiceForIntegration, } from '@/lib/integrations' import { credentialProviderMatchesService } from '@/lib/oauth' import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' -import { RESOURCE_TILE_BASE } from '@/app/workspace/[workspaceId]/components/resource-tile' import { IntegrationSkillsSection } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section' import { connectParam } from '@/app/workspace/[workspaceId]/integrations/[block]/search-params' import { @@ -34,7 +32,7 @@ import { SettingsResourceRow, } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' -import { getTileIconColorClass } from '@/blocks/icon-color' +import { getBlockTileIcon } from '@/blocks/accent' import { storeCuratedPrompt } from '@/blocks/integration-matcher' import { getSuggestedSkillsForBlock, @@ -64,7 +62,6 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration useOAuthReturnRouter() const router = useRouter() const [connectMode, setConnectMode] = useQueryState(connectParam.key, connectParam.parser) - const Icon = blockTypeToIconMap[integration.type] const matchingTemplates = getTemplatesForBlock(integration.type) const suggestedSkills = getSuggestedSkillsForBlock(integration.type) const oauthService = resolveOAuthServiceForIntegration(integration) @@ -233,16 +230,10 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration > - {Icon ? ( - - ) : ( - - {integration.name.charAt(0)} - - )} + {integration.name} {integration.description} @@ -255,7 +246,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration } + icon={} title={credential.displayName} description={ credential.description || resolveCredentialDisplay(credential).subtitle @@ -374,8 +365,7 @@ function TemplateIcons({ blockTypes }: TemplateIconsProps) { return ( {blockTypes.map((bt, idx) => { - const ToolIcon = blockTypeToIconMap[bt] - if (!ToolIcon) return null + if (!getBlockTileIcon(bt)) return null const z = TEMPLATE_TILE_Z[idx] if (!z) return null const isTrailing = idx > 0 @@ -389,7 +379,7 @@ function TemplateIcons({ blockTypes }: TemplateIconsProps) { 'outline outline-2 outline-[var(--bg)] transition-[outline-color] duration-150 group-hover:outline-[var(--surface-active)]' )} > - + ) })} diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx index 43a25c08ba1..e4b29e32451 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx @@ -5,6 +5,7 @@ import { RESOURCE_TILE_PLAIN, } from '@/app/workspace/[workspaceId]/components/resource-tile' import { getBlock } from '@/blocks' +import { getBlockTileIcon } from '@/blocks/accent' import { getTileIconColorClass } from '@/blocks/icon-color' /** @@ -59,7 +60,15 @@ function resolveBrandTileBg(blockType: string): string | null { interface IntegrationTileProps { blockType: string - icon: ComponentType<{ className?: string }> + /** + * Overrides the block's registered mark. Only for a tile whose identity is + * not the block itself — a credential issued by a family service account + * wears the family's corporate mark. Everything else takes the registry's, + * so the tile cannot end up with its fill and its icon from two sources. + */ + icon?: ComponentType<{ className?: string }> + /** Drawn when neither the override nor the registry supplies a mark. */ + fallbackLabel?: string framed?: boolean } @@ -68,16 +77,23 @@ interface IntegrationTileProps { * is a 36px tile used in list rows and headers; the framed variant adds an * outer 44px halo used inside the showcase grid. */ -export function IntegrationTile({ blockType, icon: Icon, framed = false }: IntegrationTileProps) { +export function IntegrationTile({ + blockType, + icon, + fallbackLabel, + framed = false, +}: IntegrationTileProps) { const brandBg = resolveBrandTileBg(blockType) + const Icon = icon ?? getBlockTileIcon(blockType) + const contentClass = getTileIconColorClass(brandBg) if (!framed) { return ( - + {Icon ? : fallbackLabel} ) } @@ -85,10 +101,13 @@ export function IntegrationTile({ blockType, icon: Icon, framed = false }: Integ return ( - + {Icon ? : fallbackLabel} ) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx index 2b1ae99f392..705f6647dfd 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx @@ -8,7 +8,6 @@ import { ChipInput, ChipLink, ChipTextarea, - cn, Send, toast, } from '@sim/emcn' @@ -28,10 +27,6 @@ import { UnsavedChangesModal, useCredentialDetailForm, } from '@/app/workspace/[workspaceId]/components/credential-detail' -import { - RESOURCE_TILE_BASE, - RESOURCE_TILE_PLAIN, -} from '@/app/workspace/[workspaceId]/components/resource-tile' import { ConnectServiceAccountModal, type ServiceAccountProviderId, @@ -244,15 +239,11 @@ export function ConnectedCredentialDetail({ - ) : ( - - - {resolveProviderLabel(credential.providerId).slice(0, 1) || '?'} - - - ) + } title={headingTitle} subtitle={display?.detailSubtitle ?? 'Connected service'} diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx index 9f166f59e8b..3f26e43d390 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx @@ -14,7 +14,6 @@ import { import { useParams } from 'next/navigation' import { useQueryStates } from 'nuqs' import { - blockTypeToIconMap, formatIntegrationType, INTEGRATIONS, type Integration, @@ -34,6 +33,7 @@ import { } from '@/app/workspace/[workspaceId]/integrations/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { getBlockTileIcon } from '@/blocks/accent' import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { usePermissionConfig } from '@/hooks/use-permission-config' @@ -68,7 +68,6 @@ interface IntegrationItemProps { workspaceId: string name: string description?: string | null - icon: ComponentType<{ className?: string }> unavailable?: boolean } @@ -78,13 +77,12 @@ function IntegrationItem({ workspaceId, name, description, - icon: Icon, unavailable = false, }: IntegrationItemProps) { return ( } + icon={} title={name} description={ unavailable @@ -348,8 +346,7 @@ export function Integrations() { {filteredCategorySections.map((section) => ( {section.integrations.map((integration) => { - const Icon = blockTypeToIconMap[integration.type] - if (!Icon) return null + if (!getBlockTileIcon(integration.type)) return null const availability = integrationAvailability.get(integration.type.toLowerCase()) const deploymentUnavailable = availability?.state === 'unavailable' || availability?.state === 'misconfigured' @@ -361,7 +358,6 @@ export function Integrations() { workspaceId={workspaceId} name={integration.name} description={integration.description} - icon={Icon} unavailable={integration.authType === 'oauth' && deploymentUnavailable} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx index 705746d348f..56a5cb790c6 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx @@ -16,7 +16,6 @@ import { ChipModalFooter, ChipModalHeader, type ComboboxOption, - cn, handleKeyboardActivation, Search, } from '@sim/emcn' @@ -31,12 +30,11 @@ import { import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' import { ConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields' import { hasWorkspaceMaxConnectorAccess } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements' +import { ConnectorTile } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-tile' import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts' import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge' import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' -import { getBlock } from '@/blocks' -import { getTileIconColorClass } from '@/blocks/icon-color' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' import type { ConnectorMeta } from '@/connectors/types' import { useCreateConnector } from '@/hooks/queries/kb/connectors' @@ -471,9 +469,6 @@ interface ConnectorTypeCardProps { } function ConnectorTypeCard({ type, config, onClick }: ConnectorTypeCardProps) { - const Icon = config.icon - const brandBg = getBlock(type)?.bgColor ?? null - return ( - - - + {config.name} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-tile/connector-tile.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-tile/connector-tile.tsx new file mode 100644 index 00000000000..b3a899c3728 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-tile/connector-tile.tsx @@ -0,0 +1,44 @@ +'use client' + +import type { ComponentType } from 'react' +import { cn } from '@sim/emcn' +import { getBlock } from '@/blocks' +import { getTileIconColorClass } from '@/blocks/icon-color' + +interface ConnectorTileProps { + /** Connector type, which doubles as the block type owning the brand colour. */ + connectorType: string + icon?: ComponentType<{ className?: string }> +} + +/** + * 36px brand tile for a knowledge-base connector. The fill comes from the block + * registry so a connector reads the same as the integration it syncs from; + * connectors carry an icon of their own but no colour, which is why the two are + * resolved from different places here. + * + * A connector whose type has no block config keeps the neutral surface rather + * than inventing a colour. + */ +export function ConnectorTile({ connectorType, icon: Icon }: ConnectorTileProps) { + const brandBg = getBlock(connectorType)?.bgColor ?? null + + return ( + + {Icon && ( + + )} + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-tile/index.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-tile/index.ts new file mode 100644 index 00000000000..5250cae67e9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-tile/index.ts @@ -0,0 +1 @@ +export { ConnectorTile } from './connector-tile' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx index 8db6e3e7c87..baf4d9b46e0 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx @@ -32,9 +32,8 @@ import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/creden import { getCanonicalScopesForProvider, getProviderIdFromServiceId } from '@/lib/oauth' import { getMissingRequiredScopes } from '@/lib/oauth/utils' import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' +import { ConnectorTile } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-tile' import { EditConnectorModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal' -import { getBlock } from '@/blocks' -import { getTileIconColorClass } from '@/blocks/icon-color' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' import type { ConnectorData, SyncLogData } from '@/hooks/queries/kb/connectors' import { @@ -309,7 +308,6 @@ function ConnectorCard({ const connectorDef = CONNECTOR_META_REGISTRY[connector.connectorType] const Icon = connectorDef?.icon - const brandBg = getBlock(connector.connectorType)?.bgColor ?? null const statusConfig = STATUS_CONFIG[connector.status as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.active @@ -359,24 +357,7 @@ function ConnectorCard({ - - {Icon && ( - - )} - + {connector.status === 'disabled' && ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts index b3dc95416bb..7ac4eb889de 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts @@ -7,6 +7,7 @@ import type { TraceSpan } from '@/lib/logs/types' 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 { getBlock, getBlockByToolName } from '@/blocks' +import { DEFAULT_BLOCK_TILE_COLOR } from '@/blocks/accent' import { PROVIDER_DEFINITIONS } from '@/providers/models' import { normalizeToolId } from '@/tools/normalize' @@ -24,8 +25,6 @@ function tryParseMcpToolName(toolId: string): string | null { return toolName.length > 0 ? toolName : null } -export const DEFAULT_BLOCK_COLOR = '#6b7280' - export interface BlockIconAndColor { icon: React.ComponentType<{ className?: string }> | null bgColor: string @@ -71,12 +70,12 @@ export function getBlockIconAndColor( if (lowerType === 'model' && provider) { const providerDef = PROVIDER_DEFINITIONS[provider] if (providerDef?.icon) - return { icon: providerDef.icon, bgColor: providerDef.color ?? DEFAULT_BLOCK_COLOR } + return { icon: providerDef.icon, bgColor: providerDef.color ?? DEFAULT_BLOCK_TILE_COLOR } } const blockType = lowerType === 'model' ? 'agent' : lowerType const blockConfig = getBlock(blockType) if (blockConfig) return { icon: blockConfig.icon, bgColor: blockConfig.bgColor } - return { icon: null, bgColor: DEFAULT_BLOCK_COLOR } + return { icon: null, bgColor: DEFAULT_BLOCK_TILE_COLOR } } /** @@ -93,7 +92,9 @@ const MAX_YIQ_SUM = 255_000 */ export function adjustBgForContrast(bgColor: string): string { const brightness = perceivedBrightness(bgColor) - return brightness !== null && brightness < 30_000 / MAX_YIQ_SUM ? DEFAULT_BLOCK_COLOR : bgColor + return brightness !== null && brightness < 30_000 / MAX_YIQ_SUM + ? DEFAULT_BLOCK_TILE_COLOR + : bgColor } export function parseTime(value?: string | number | null): number { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts index af5cceea88c..a61e953328c 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts @@ -1,9 +1,13 @@ import type React from 'react' import type { ColumnDefinition } from '@/lib/table' +/** + * The producing block's mark for a workflow-output column. Icon only — these + * render in the plain `--text-icon` tone like every other column-type icon, so + * carrying a colour here only invited a second copy of the block's `bgColor`. + */ export interface BlockIconInfo { icon: React.ComponentType<{ className?: string }> - color: string } export interface ColumnSourceInfo { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts index be473ae71d3..62fac4b055f 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts @@ -244,7 +244,7 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams) const block = blocks?.[out.blockId] const blockConfig = block?.type ? getBlock(block.type) : undefined const blockIconInfo: BlockIconInfo | undefined = blockConfig?.icon - ? { icon: blockConfig.icon, color: blockConfig.bgColor || '#2F55FF' } + ? { icon: blockConfig.icon } : undefined const blockName = block?.name?.trim() || undefined // Flag a missing source block only once the workflow state has loaded diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts index d9cdf9702ac..bb894543814 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts @@ -1,266 +1,4 @@ -import type { ChatContext } from '@/stores/panel' - -/** - * Mention folder types - */ -export type MentionFolderId = - | 'chats' - | 'workflows' - | 'knowledge' - | 'blocks' - | 'workflow-blocks' - | 'logs' - | 'integrations' - -/** - * Menu item category types for mention menu (includes folders + docs item) - */ -export type MentionCategory = MentionFolderId | 'docs' - -/** - * Configuration interface for folder types - */ -export interface FolderConfig { - /** Display title in menu */ - title: string - /** Data source key in useMentionData return */ - dataKey: string - /** Loading state key in useMentionData return */ - loadingKey: string - /** Ensure loaded function key in useMentionData return (optional - some folders auto-load) */ - ensureLoadedKey?: string - /** Extract label from an item */ - getLabel: (item: TItem) => string - /** Extract unique ID from an item */ - getId: (item: TItem) => string - /** Empty state message */ - emptyMessage: string - /** No match message (when filtering) */ - noMatchMessage: string - /** Filter function for matching query */ - filterFn: (item: TItem, query: string) => boolean - /** Build the ChatContext object from an item */ - buildContext: (item: TItem, workflowId?: string | null) => ChatContext - /** Whether to use insertAtCursor fallback when replaceActiveMentionWith fails */ - useInsertFallback?: boolean -} - -/** - * Configuration for all folder types in the mention menu - */ -export const FOLDER_CONFIGS: Record = { - chats: { - title: 'Chats', - dataKey: 'pastChats', - loadingKey: 'isLoadingPastChats', - ensureLoadedKey: 'ensurePastChatsLoaded', - getLabel: (item) => item.title || 'New Chat', - getId: (item) => item.id, - emptyMessage: 'No past chats', - noMatchMessage: 'No matching chats', - filterFn: (item, q) => (item.title || 'New Chat').toLowerCase().includes(q), - buildContext: (item) => ({ - kind: 'past_chat', - chatId: item.id, - label: item.title || 'New Chat', - }), - useInsertFallback: false, - }, - workflows: { - title: 'All workflows', - dataKey: 'workflows', - loadingKey: 'isLoadingWorkflows', - getLabel: (item) => item.name || 'Untitled Workflow', - getId: (item) => item.id, - emptyMessage: 'No workflows', - noMatchMessage: 'No matching workflows', - filterFn: (item, q) => (item.name || 'Untitled Workflow').toLowerCase().includes(q), - buildContext: (item) => ({ - kind: 'workflow', - workflowId: item.id, - label: item.name || 'Untitled Workflow', - }), - useInsertFallback: true, - }, - knowledge: { - title: 'Knowledge Bases', - dataKey: 'knowledgeBases', - loadingKey: 'isLoadingKnowledge', - ensureLoadedKey: 'ensureKnowledgeLoaded', - getLabel: (item) => item.name || 'Untitled', - getId: (item) => item.id, - emptyMessage: 'No knowledge bases', - noMatchMessage: 'No matching knowledge bases', - filterFn: (item, q) => (item.name || 'Untitled').toLowerCase().includes(q), - buildContext: (item) => ({ - kind: 'knowledge', - knowledgeId: item.id, - label: item.name || 'Untitled', - }), - useInsertFallback: false, - }, - blocks: { - title: 'Blocks', - dataKey: 'blocksList', - loadingKey: 'isLoadingBlocks', - ensureLoadedKey: 'ensureBlocksLoaded', - getLabel: (item) => item.name || item.id, - getId: (item) => item.id, - emptyMessage: 'No blocks found', - noMatchMessage: 'No matching blocks', - filterFn: (item, q) => (item.name || item.id).toLowerCase().includes(q), - buildContext: (item) => ({ - kind: 'blocks', - blockIds: [item.id], - label: item.name || item.id, - }), - useInsertFallback: false, - }, - 'workflow-blocks': { - title: 'Workflow Blocks', - dataKey: 'workflowBlocks', - loadingKey: 'isLoadingWorkflowBlocks', - // No ensureLoadedKey - workflow blocks auto-sync from store - getLabel: (item) => item.name || item.id, - getId: (item) => item.id, - emptyMessage: 'No blocks in this workflow', - noMatchMessage: 'No matching blocks', - filterFn: (item, q) => (item.name || item.id).toLowerCase().includes(q), - buildContext: (item, workflowId) => ({ - kind: 'workflow_block', - workflowId: workflowId || '', - blockId: item.id, - label: item.name || item.id, - }), - useInsertFallback: true, - }, - logs: { - title: 'Logs', - dataKey: 'logsList', - loadingKey: 'isLoadingLogs', - ensureLoadedKey: 'ensureLogsLoaded', - getLabel: (item) => item.workflowName, - getId: (item) => item.id, - emptyMessage: 'No executions found', - noMatchMessage: 'No matching executions', - filterFn: (item, q) => - [item.workflowName, item.trigger || ''].join(' ').toLowerCase().includes(q), - buildContext: (item) => ({ - kind: 'logs', - executionId: item.executionId || item.id, - label: item.workflowName, - }), - useInsertFallback: false, - }, - integrations: { - title: 'Integrations', - dataKey: 'integrations', - loadingKey: 'isLoadingIntegrations', - getLabel: (item) => item.name, - getId: (item) => item.blockType, - emptyMessage: 'No integrations', - noMatchMessage: 'No matching integrations', - filterFn: (item, q) => item.name.toLowerCase().includes(q), - buildContext: (item) => ({ - kind: 'integration', - blockType: item.blockType, - label: item.name, - }), - useInsertFallback: true, - }, -} - -/** - * Order of folders in the mention menu - */ -export const FOLDER_ORDER: MentionFolderId[] = [ - 'chats', - 'workflows', - 'knowledge', - 'blocks', - 'workflow-blocks', - 'integrations', - 'logs', -] - -/** - * Docs item configuration (special case - not a folder) - */ -export const DOCS_CONFIG = { - getLabel: () => 'Docs', - buildContext: (): ChatContext => ({ kind: 'docs', label: 'Docs' }), -} as const - -/** - * Total number of items in root menu (folders + docs) - */ -export const ROOT_MENU_ITEM_COUNT = FOLDER_ORDER.length + 1 - -/** - * Slash command configuration - */ -export interface SlashCommand { - id: string - label: string -} - -export const TOP_LEVEL_COMMANDS: readonly SlashCommand[] = [ - { id: 'fast', label: 'Fast' }, - { id: 'research', label: 'Research' }, - { id: 'actions', label: 'Actions' }, -] as const - -/** - * Maps UI command IDs to API command IDs. - * Some commands have different IDs for display vs API (e.g., "actions" -> "superagent") - */ -export function getApiCommandId(uiCommandId: string): string { - const commandMapping: Record = { - actions: 'superagent', - } - return commandMapping[uiCommandId] || uiCommandId -} - -export const WEB_COMMANDS: readonly SlashCommand[] = [ - { id: 'search', label: 'Search' }, - { id: 'read', label: 'Read' }, - { id: 'scrape', label: 'Scrape' }, - { id: 'crawl', label: 'Crawl' }, -] as const - -export const ALL_SLASH_COMMANDS: readonly SlashCommand[] = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS] - -export const ALL_COMMAND_IDS = ALL_SLASH_COMMANDS.map((cmd) => cmd.id) - -/** - * Get display label for a command ID - */ -export function getCommandDisplayLabel(commandId: string): string { - const command = ALL_SLASH_COMMANDS.find((cmd) => cmd.id === commandId) - return command?.label || commandId.charAt(0).toUpperCase() + commandId.slice(1) -} - -/** - * Threshold for considering input "near top" of viewport (in pixels) - */ -export const NEAR_TOP_THRESHOLD = 300 - /** * Scroll tolerance for mention menu positioning (in pixels) */ export const SCROLL_TOLERANCE = 8 - -/** - * Shared CSS classes for menu state text (loading, empty states) - */ -export const MENU_STATE_TEXT_CLASSES = 'px-2 py-2 text-caption text-[var(--text-muted)]' - -/** - * Calculates the next index for circular navigation (wraps around at bounds) - */ -export function getNextIndex(current: number, direction: 'up' | 'down', maxIndex: number): number { - if (direction === 'down') { - return current >= maxIndex ? 0 : current + 1 - } - return current <= 0 ? maxIndex : current - 1 -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/index.ts index 254349f067b..f17c6d0102e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/index.ts @@ -1,10 +1,5 @@ -export { useCaretViewport } from './use-caret-viewport' export { useContextManagement } from './use-context-management' export { useFileAttachments } from './use-file-attachments' export { useIntegrationAutoMention } from './use-integration-auto-mention' -export { useMentionData } from './use-mention-data' -export { useMentionInsertHandlers } from './use-mention-insert-handlers' -export { useMentionKeyboard } from './use-mention-keyboard' export { useMentionMenu } from './use-mention-menu' export { useMentionTokens } from './use-mention-tokens' -export { useTextareaAutoResize } from './use-textarea-auto-resize' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-caret-viewport.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-caret-viewport.ts deleted file mode 100644 index 51cc9212289..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-caret-viewport.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { useMemo } from 'react' - -interface CaretViewportPosition { - left: number - top: number -} - -interface UseCaretViewportResult { - caretViewport: CaretViewportPosition | null - side: 'top' | 'bottom' -} - -interface UseCaretViewportProps { - textareaRef: React.RefObject - message: string - caretPos: number -} - -/** - * Calculates the viewport position of the caret in a textarea using the mirror div technique. - * This hook memoizes the calculation to prevent unnecessary DOM manipulation on every render. - */ -export function useCaretViewport({ - textareaRef, - message, - caretPos, -}: UseCaretViewportProps): UseCaretViewportResult { - return useMemo(() => { - const textareaEl = textareaRef.current - if (!textareaEl) { - return { caretViewport: null, side: 'bottom' as const } - } - - const textareaRect = textareaEl.getBoundingClientRect() - const style = window.getComputedStyle(textareaEl) - - const mirrorDiv = document.createElement('div') - mirrorDiv.style.position = 'absolute' - mirrorDiv.style.visibility = 'hidden' - mirrorDiv.style.whiteSpace = 'pre-wrap' - mirrorDiv.style.overflowWrap = 'break-word' - mirrorDiv.style.font = style.font - mirrorDiv.style.padding = style.padding - mirrorDiv.style.border = style.border - mirrorDiv.style.width = style.width - mirrorDiv.style.lineHeight = style.lineHeight - mirrorDiv.style.boxSizing = style.boxSizing - mirrorDiv.style.letterSpacing = style.letterSpacing - mirrorDiv.style.textTransform = style.textTransform - mirrorDiv.style.textIndent = style.textIndent - mirrorDiv.style.textAlign = style.textAlign - mirrorDiv.textContent = message.substring(0, caretPos) - - const caretMarker = document.createElement('span') - caretMarker.style.display = 'inline-block' - caretMarker.style.width = '0px' - caretMarker.style.padding = '0' - caretMarker.style.border = '0' - mirrorDiv.appendChild(caretMarker) - - document.body.appendChild(mirrorDiv) - const markerRect = caretMarker.getBoundingClientRect() - const mirrorRect = mirrorDiv.getBoundingClientRect() - document.body.removeChild(mirrorDiv) - - const caretViewport = { - left: textareaRect.left + (markerRect.left - mirrorRect.left) - textareaEl.scrollLeft, - top: textareaRect.top + (markerRect.top - mirrorRect.top) - textareaEl.scrollTop, - } - - const margin = 8 - const spaceBelow = window.innerHeight - caretViewport.top - margin - const side: 'top' | 'bottom' = spaceBelow >= caretViewport.top - margin ? 'bottom' : 'top' - - return { caretViewport, side } - }, [textareaRef, message, caretPos]) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts deleted file mode 100644 index 2736c964f75..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts +++ /dev/null @@ -1,365 +0,0 @@ -'use client' - -import { useCallback, useEffect, useState } from 'react' -import { createLogger } from '@sim/logger' -import { useShallow } from 'zustand/react/shallow' -import { requestJson } from '@/lib/api/client/request' -import { listCopilotChatsContract } from '@/lib/api/contracts/copilot' -import { listKnowledgeBasesContract } from '@/lib/api/contracts/knowledge/base' -import { listLogsContract } from '@/lib/api/contracts/logs' -import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' -import { type IntegrationDescriptor, listIntegrations } from '@/blocks/integration-matcher' -import { useWorkflows } from '@/hooks/queries/workflows' -import { usePermissionConfig } from '@/hooks/use-permission-config' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' - -const logger = createLogger('useMentionData') - -/** - * Represents a past chat for mention suggestions - */ -export interface PastChat { - id: string - title: string | null - workflowId: string | null - updatedAt?: string -} - -/** - * Represents a workflow for mention suggestions - */ -export interface WorkflowItem { - id: string - name: string - color?: string -} - -/** - * Represents a knowledge base for mention suggestions - */ -export interface KnowledgeItem { - id: string - name: string -} - -/** - * Represents a block for mention suggestions - */ -export interface BlockItem { - id: string - name: string - iconComponent?: any - bgColor?: string -} - -/** - * Represents a workflow block for mention suggestions - */ -export interface WorkflowBlockItem { - id: string - name: string - type: string - iconComponent?: any - bgColor?: string -} - -/** - * Represents a log/execution for mention suggestions - */ -export interface LogItem { - id: string - executionId?: string - level: string - trigger: string | null - createdAt: string - workflowName: string -} - -interface UseMentionDataProps { - workflowId: string | null - workspaceId: string -} - -/** - * Return type for useMentionData hook - */ -export interface MentionDataReturn { - // Data arrays - pastChats: PastChat[] - workflows: WorkflowItem[] - knowledgeBases: KnowledgeItem[] - blocksList: BlockItem[] - workflowBlocks: WorkflowBlockItem[] - logsList: LogItem[] - integrations: readonly IntegrationDescriptor[] - - // Loading states - isLoadingPastChats: boolean - isLoadingWorkflows: boolean - isLoadingKnowledge: boolean - isLoadingBlocks: boolean - isLoadingWorkflowBlocks: boolean - isLoadingLogs: boolean - isLoadingIntegrations: boolean - - // Ensure loaded functions - ensurePastChatsLoaded: () => Promise - ensureKnowledgeLoaded: () => Promise - ensureBlocksLoaded: () => Promise - ensureLogsLoaded: () => Promise -} - -/** - * Custom hook to fetch and manage data for mention suggestions - * Loads data from APIs for chats, workflows, knowledge bases, blocks, and logs - * - * @param props - Configuration including workflow and workspace IDs - * @returns Mention data state and loading operations - */ -export function useMentionData(props: UseMentionDataProps): MentionDataReturn { - const { workflowId, workspaceId } = props - - const { config, isBlockAllowed } = usePermissionConfig() - - const [pastChats, setPastChats] = useState([]) - const [isLoadingPastChats, setIsLoadingPastChats] = useState(false) - - const [knowledgeBases, setKnowledgeBases] = useState([]) - const [isLoadingKnowledge, setIsLoadingKnowledge] = useState(false) - - const [blocksList, setBlocksList] = useState([]) - const [isLoadingBlocks, setIsLoadingBlocks] = useState(false) - - // Reset on permission changes and on block-overlay bumps (custom-block or - // block-visibility hydrate) so late preview reveals refresh the folder. - const blockOverlayVersion = useCustomBlockOverlayVersion() - useEffect(() => { - setBlocksList([]) - }, [config.allowedIntegrations, blockOverlayVersion]) - - const [logsList, setLogsList] = useState([]) - const [isLoadingLogs, setIsLoadingLogs] = useState(false) - - const [workflowBlocks, setWorkflowBlocks] = useState([]) - const [isLoadingWorkflowBlocks, setIsLoadingWorkflowBlocks] = useState(false) - - // Integrations are derived synchronously from the block registry via the - // shared auto-mention matcher singleton — no fetch, no loading state. The - // accessor returns a stable cached reference so no memoization is needed. - const integrations = listIntegrations() - - const blockKeys = useWorkflowStore( - useShallow(useCallback((state) => Object.keys(state.blocks), [])) - ) - - const { data: registryWorkflowList = [] } = useWorkflows(workspaceId) - const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase) - const isLoadingWorkflows = hydrationPhase === 'idle' || hydrationPhase === 'state-loading' - - const workflows: WorkflowItem[] = registryWorkflowList - .filter((w) => w.workspaceId === workspaceId) - .sort((a, b) => { - const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0 - const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0 - return dateB - dateA - }) - .map((w) => ({ - id: w.id, - name: w.name || 'Untitled Workflow', - })) - - /** - * Resets past chats when workflow changes - */ - useEffect(() => { - setPastChats([]) - setIsLoadingPastChats(false) - }, [workflowId]) - - /** - * Syncs workflow blocks from store - * Only re-runs when blocks are added/removed (not on position updates) - */ - useEffect(() => { - const syncWorkflowBlocks = async () => { - if (!workflowId || blockKeys.length === 0) { - setWorkflowBlocks([]) - return - } - - try { - // Fetch current blocks from store - const workflowStoreBlocks = useWorkflowStore.getState().blocks - - const { getBlockRegistry } = await import('@/blocks/registry') - const blockRegistry = getBlockRegistry() - const mapped = Object.values(workflowStoreBlocks).map((b: any) => { - const reg = (blockRegistry as any)[b.type] - return { - id: b.id, - name: b.name || b.id, - type: b.type, - iconComponent: reg?.icon, - bgColor: reg?.bgColor || '#6B7280', - } - }) - setWorkflowBlocks(mapped) - logger.debug('Synced workflow blocks for mention menu', { - count: mapped.length, - }) - } catch (error) { - logger.debug('Failed to sync workflow blocks:', error) - } - } - - syncWorkflowBlocks() - }, [blockKeys, workflowId]) - - /** - * Ensures past chats are loaded - */ - const ensurePastChatsLoaded = useCallback(async () => { - if (isLoadingPastChats || pastChats.length > 0) return - try { - setIsLoadingPastChats(true) - const data = await requestJson(listCopilotChatsContract, {}) - const items = data.chats - - const currentWorkflowChats = items.filter((c) => c.workflowId === workflowId) - - setPastChats( - currentWorkflowChats.map((c) => ({ - id: c.id, - title: c.title ?? null, - workflowId: c.workflowId ?? null, - updatedAt: c.updatedAt ?? undefined, - })) - ) - } catch { - } finally { - setIsLoadingPastChats(false) - } - }, [isLoadingPastChats, pastChats.length, workflowId]) - - /** - * Ensures knowledge bases are loaded - */ - const ensureKnowledgeLoaded = useCallback(async () => { - if (isLoadingKnowledge || knowledgeBases.length > 0) return - try { - setIsLoadingKnowledge(true) - const result = await requestJson(listKnowledgeBasesContract, { - query: { workspaceId }, - }) - const items = result.data - const sorted = [...items].sort((a, b) => { - const ta = new Date(a.updatedAt || a.createdAt || 0).getTime() - const tb = new Date(b.updatedAt || b.createdAt || 0).getTime() - return tb - ta - }) - setKnowledgeBases(sorted.map((k) => ({ id: k.id, name: k.name || 'Untitled' }))) - } catch { - } finally { - setIsLoadingKnowledge(false) - } - }, [isLoadingKnowledge, knowledgeBases.length, workspaceId]) - - /** - * Ensures blocks are loaded - */ - const ensureBlocksLoaded = useCallback(async () => { - if (isLoadingBlocks || blocksList.length > 0) return - try { - setIsLoadingBlocks(true) - const { getAllBlocks } = await import('@/blocks') - const all = getAllBlocks() - const regularBlocks = all - .filter( - (b: any) => - b.type !== 'starter' && - !b.hideFromToolbar && - b.category === 'blocks' && - isBlockAllowed(b.type) - ) - .map((b: any) => ({ - id: b.type, - name: b.name || b.type, - iconComponent: b.icon, - bgColor: b.bgColor, - })) - .sort((a: any, b: any) => a.name.localeCompare(b.name)) - - const toolBlocks = all - .filter( - (b: any) => - b.type !== 'starter' && - !b.hideFromToolbar && - b.category === 'tools' && - isBlockAllowed(b.type) - ) - .map((b: any) => ({ - id: b.type, - name: b.name || b.type, - iconComponent: b.icon, - bgColor: b.bgColor, - })) - .sort((a: any, b: any) => a.name.localeCompare(b.name)) - - setBlocksList([...regularBlocks, ...toolBlocks]) - } catch { - } finally { - setIsLoadingBlocks(false) - } - }, [isLoadingBlocks, blocksList.length, isBlockAllowed]) - - /** - * Ensures logs are loaded - */ - const ensureLogsLoaded = useCallback(async () => { - if (isLoadingLogs || logsList.length > 0) return - try { - setIsLoadingLogs(true) - const data = await requestJson(listLogsContract, { - query: { workspaceId, limit: 50 }, - }) - const items = data.data - const mapped = items.map((l) => ({ - id: l.id, - executionId: l.executionId || l.id, - level: l.level, - trigger: l.trigger || null, - createdAt: l.createdAt, - workflowName: l.workflow?.name ?? 'Untitled Workflow', - })) - setLogsList(mapped) - } catch { - } finally { - setIsLoadingLogs(false) - } - }, [isLoadingLogs, logsList.length, workspaceId]) - - return { - // State - pastChats, - isLoadingPastChats, - workflows, - isLoadingWorkflows, - knowledgeBases, - isLoadingKnowledge, - blocksList, - isLoadingBlocks, - logsList, - isLoadingLogs, - workflowBlocks, - isLoadingWorkflowBlocks, - integrations, - isLoadingIntegrations: false, - - // Operations - ensurePastChatsLoaded, - ensureKnowledgeLoaded, - ensureBlocksLoaded, - ensureLogsLoaded, - } -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-insert-handlers.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-insert-handlers.ts deleted file mode 100644 index 75eb4f7ec50..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-insert-handlers.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { useCallback, useMemo } from 'react' -import { - DOCS_CONFIG, - FOLDER_CONFIGS, - type FolderConfig, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants' -import type { useMentionMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu' -import type { MentionFolderNav } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/types' -import { isContextAlreadySelected } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' -import type { ChatContext } from '@/stores/panel' - -interface UseMentionInsertHandlersProps { - /** Mention menu hook instance */ - mentionMenu: ReturnType - /** Current workflow ID */ - workflowId: string | null - /** Currently selected contexts */ - selectedContexts: ChatContext[] - /** Callback to update selected contexts */ - onContextAdd: (context: ChatContext) => void - /** Folder navigation state exposed from MentionMenu via callback */ - mentionFolderNav?: MentionFolderNav | null -} - -/** - * Custom hook to provide insert handlers for different mention types. - * - * @param props - Configuration object - * @returns Insert handler functions for each mention type - */ -export function useMentionInsertHandlers({ - mentionMenu, - workflowId, - selectedContexts, - onContextAdd, - mentionFolderNav, -}: UseMentionInsertHandlersProps) { - const { - replaceActiveMentionWith, - insertAtCursor, - setShowMentionMenu, - setOpenSubmenuFor, - resetActiveMentionQuery, - } = mentionMenu - - /** - * Closes all menus and resets state - */ - const closeMenus = useCallback(() => { - setShowMentionMenu(false) - if (mentionFolderNav?.isInFolder) { - mentionFolderNav.closeFolder() - } - setOpenSubmenuFor(null) - }, [setShowMentionMenu, setOpenSubmenuFor, mentionFolderNav]) - - const createInsertHandler = useCallback( - (config: FolderConfig) => { - return (item: TItem) => { - const label = config.getLabel(item) - const context = config.buildContext(item, workflowId) - - if (isContextAlreadySelected(context, selectedContexts)) { - resetActiveMentionQuery() - closeMenus() - return - } - - if (config.useInsertFallback) { - if (!replaceActiveMentionWith(label)) { - insertAtCursor(` @${label} `) - } - } else { - replaceActiveMentionWith(label) - } - - onContextAdd(context) - closeMenus() - } - }, - [ - workflowId, - selectedContexts, - replaceActiveMentionWith, - insertAtCursor, - onContextAdd, - resetActiveMentionQuery, - closeMenus, - ] - ) - - /** - * Special handler for Docs (no item parameter, uses DOCS_CONFIG) - */ - const insertDocsMention = useCallback(() => { - const label = DOCS_CONFIG.getLabel() - const context = DOCS_CONFIG.buildContext() - - // Prevent duplicate insertion - if (isContextAlreadySelected(context, selectedContexts)) { - resetActiveMentionQuery() - closeMenus() - return - } - - // Docs uses fallback insertion - if (!replaceActiveMentionWith(label)) { - insertAtCursor(` @${label} `) - } - - onContextAdd(context) - closeMenus() - }, [ - selectedContexts, - replaceActiveMentionWith, - insertAtCursor, - onContextAdd, - resetActiveMentionQuery, - closeMenus, - ]) - - const handlers = useMemo( - () => ({ - insertPastChatMention: createInsertHandler(FOLDER_CONFIGS.chats), - insertWorkflowMention: createInsertHandler(FOLDER_CONFIGS.workflows), - insertKnowledgeMention: createInsertHandler(FOLDER_CONFIGS.knowledge), - insertBlockMention: createInsertHandler(FOLDER_CONFIGS.blocks), - insertWorkflowBlockMention: createInsertHandler(FOLDER_CONFIGS['workflow-blocks']), - insertLogMention: createInsertHandler(FOLDER_CONFIGS.logs), - insertIntegrationMention: createInsertHandler(FOLDER_CONFIGS.integrations), - insertDocsMention, - }), - [createInsertHandler, insertDocsMention] - ) - - return handlers -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-keyboard.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-keyboard.ts deleted file mode 100644 index 8ab898483ff..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-keyboard.ts +++ /dev/null @@ -1,355 +0,0 @@ -import { type KeyboardEvent, useCallback, useMemo } from 'react' -import { - FOLDER_CONFIGS, - FOLDER_ORDER, - type MentionFolderId, - ROOT_MENU_ITEM_COUNT, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants' -import type { - useMentionData, - useMentionMenu, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks' -import type { MentionFolderNav } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/types' -import { - getFolderData as getFolderDataUtil, - getFolderEnsureLoaded as getFolderEnsureLoadedUtil, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' - -interface UseMentionKeyboardProps { - /** Mention menu hook instance */ - mentionMenu: ReturnType - /** Mention data hook instance */ - mentionData: ReturnType - /** Callback to insert specific mention types */ - insertHandlers: { - insertPastChatMention: (chat: any) => void - insertWorkflowMention: (wf: any) => void - insertKnowledgeMention: (kb: any) => void - insertBlockMention: (blk: any) => void - insertWorkflowBlockMention: (blk: any) => void - insertLogMention: (log: any) => void - insertIntegrationMention: (integration: any) => void - insertDocsMention: () => void - } - /** Folder navigation state exposed from MentionMenu via callback */ - mentionFolderNav: MentionFolderNav | null -} - -/** - * Custom hook to handle keyboard navigation in the mention menu. - */ -export function useMentionKeyboard({ - mentionMenu, - mentionData, - insertHandlers, - mentionFolderNav, -}: UseMentionKeyboardProps) { - const { - showMentionMenu, - mentionActiveIndex, - submenuActiveIndex, - setMentionActiveIndex, - setSubmenuActiveIndex, - setSubmenuQueryStart, - getCaretPos, - getActiveMentionQueryAtPosition, - getSubmenuQuery, - resetActiveMentionQuery, - scrollActiveItemIntoView, - } = mentionMenu - - const currentFolder = mentionFolderNav?.currentFolder ?? null - const isInFolder = mentionFolderNav?.isInFolder ?? false - - /** - * Map of folder IDs to insert handlers - */ - const insertHandlerMap = useMemo( - (): Record void> => ({ - chats: insertHandlers.insertPastChatMention, - workflows: insertHandlers.insertWorkflowMention, - knowledge: insertHandlers.insertKnowledgeMention, - blocks: insertHandlers.insertBlockMention, - 'workflow-blocks': insertHandlers.insertWorkflowBlockMention, - logs: insertHandlers.insertLogMention, - integrations: insertHandlers.insertIntegrationMention, - }), - [insertHandlers] - ) - - /** - * Get data array for a folder from mentionData - */ - const getFolderData = useCallback( - (folderId: MentionFolderId) => getFolderDataUtil(mentionData, folderId), - [mentionData] - ) - - /** - * Filter items for a folder based on query using config's filterFn - */ - const filterFolderItems = useCallback( - (folderId: MentionFolderId, query: string): any[] => { - const config = FOLDER_CONFIGS[folderId] - const items = getFolderData(folderId) - if (!query) return items - const q = query.toLowerCase() - return items.filter((item) => config.filterFn(item, q)) - }, - [getFolderData] - ) - - /** - * Ensure data is loaded for a folder - */ - const ensureFolderLoaded = useCallback( - (folderId: MentionFolderId): void => { - const ensureFn = getFolderEnsureLoadedUtil(mentionData, folderId) - if (ensureFn) void ensureFn() - }, - [mentionData] - ) - - /** - * Build aggregated list matching the portal's ordering - */ - const buildAggregatedList = useCallback( - (query: string): Array<{ type: MentionFolderId | 'docs'; value: any }> => { - const q = query.toLowerCase() - const result: Array<{ type: MentionFolderId | 'docs'; value: any }> = [] - - for (const folderId of FOLDER_ORDER) { - const filtered = filterFolderItems(folderId, q) - filtered.forEach((item) => { - result.push({ type: folderId, value: item }) - }) - } - - if ('docs'.includes(q)) { - result.push({ type: 'docs', value: null }) - } - - return result - }, - [filterFolderItems] - ) - - /** - * Generic navigation helper for navigating through items - */ - const navigateItems = useCallback( - ( - direction: 'up' | 'down', - itemCount: number, - setIndex: (fn: (prev: number) => number) => void - ) => { - setIndex((prev) => { - const last = Math.max(0, itemCount - 1) - if (itemCount === 0) return 0 - const next = - direction === 'down' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1 - requestAnimationFrame(() => scrollActiveItemIntoView(next)) - return next - }) - }, - [scrollActiveItemIntoView] - ) - - /** - * Handles arrow up/down navigation in mention menu - */ - const handleArrowNavigation = useCallback( - (e: KeyboardEvent) => { - if (!showMentionMenu || !(e.key === 'ArrowDown' || e.key === 'ArrowUp')) return false - - e.preventDefault() - const caretPos = getCaretPos() - const active = getActiveMentionQueryAtPosition(caretPos) - const mainQ = (!isInFolder ? active?.query || '' : '').toLowerCase() - const direction = e.key === 'ArrowDown' ? 'down' : 'up' - - const showAggregatedView = mainQ.length > 0 - if (showAggregatedView && !isInFolder) { - const aggregatedList = buildAggregatedList(mainQ) - navigateItems(direction, aggregatedList.length, setSubmenuActiveIndex) - return true - } - - if (currentFolder && FOLDER_CONFIGS[currentFolder as MentionFolderId]) { - const q = getSubmenuQuery().toLowerCase() - const filtered = filterFolderItems(currentFolder as MentionFolderId, q) - navigateItems(direction, filtered.length, setSubmenuActiveIndex) - return true - } - - navigateItems(direction, ROOT_MENU_ITEM_COUNT, setMentionActiveIndex) - return true - }, - [ - showMentionMenu, - isInFolder, - currentFolder, - buildAggregatedList, - filterFolderItems, - navigateItems, - getCaretPos, - getActiveMentionQueryAtPosition, - getSubmenuQuery, - setMentionActiveIndex, - setSubmenuActiveIndex, - ] - ) - - /** - * Handles arrow right to enter submenus - */ - const handleArrowRight = useCallback( - (e: KeyboardEvent) => { - if (!showMentionMenu || e.key !== 'ArrowRight' || !mentionFolderNav) return false - - const caretPos = getCaretPos() - const active = getActiveMentionQueryAtPosition(caretPos) - const mainQ = (active?.query || '').toLowerCase() - - if (mainQ.length > 0) return false - - e.preventDefault() - - const isDocsSelected = mentionActiveIndex === FOLDER_ORDER.length - if (isDocsSelected) { - resetActiveMentionQuery() - insertHandlers.insertDocsMention() - return true - } - - const selectedFolderId = FOLDER_ORDER[mentionActiveIndex] - if (selectedFolderId) { - const config = FOLDER_CONFIGS[selectedFolderId] - resetActiveMentionQuery() - mentionFolderNav.openFolder(selectedFolderId, config.title) - setSubmenuQueryStart(getCaretPos()) - ensureFolderLoaded(selectedFolderId) - } - - return true - }, - [ - showMentionMenu, - mentionActiveIndex, - mentionFolderNav, - getCaretPos, - getActiveMentionQueryAtPosition, - resetActiveMentionQuery, - setSubmenuQueryStart, - ensureFolderLoaded, - insertHandlers, - ] - ) - - /** - * Handles arrow left to exit submenus - */ - const handleArrowLeft = useCallback( - (e: KeyboardEvent) => { - if (!showMentionMenu || e.key !== 'ArrowLeft') return false - - if (isInFolder && mentionFolderNav) { - e.preventDefault() - mentionFolderNav.closeFolder() - setSubmenuQueryStart(null) - return true - } - - return false - }, - [showMentionMenu, isInFolder, mentionFolderNav, setSubmenuQueryStart] - ) - - /** - * Handles Enter key to select mention - */ - const handleEnterSelection = useCallback( - (e: KeyboardEvent) => { - if (!showMentionMenu || e.key !== 'Enter' || e.shiftKey) return false - - e.preventDefault() - const caretPos = getCaretPos() - const active = getActiveMentionQueryAtPosition(caretPos) - const mainQ = (!isInFolder ? active?.query || '' : '').toLowerCase() - const showAggregatedView = mainQ.length > 0 - - if (showAggregatedView && !isInFolder) { - const aggregated = buildAggregatedList(mainQ) - const idx = Math.max(0, Math.min(submenuActiveIndex, aggregated.length - 1)) - const chosen = aggregated[idx] - if (chosen) { - if (chosen.type === 'docs') { - insertHandlers.insertDocsMention() - } else { - const handler = insertHandlerMap[chosen.type] - handler(chosen.value) - } - } - return true - } - - if (isInFolder && currentFolder && FOLDER_CONFIGS[currentFolder as MentionFolderId]) { - const folderId = currentFolder as MentionFolderId - const q = getSubmenuQuery().toLowerCase() - const filtered = filterFolderItems(folderId, q) - if (filtered.length > 0) { - const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))] - const handler = insertHandlerMap[folderId] - handler(chosen) - setSubmenuQueryStart(null) - } - return true - } - - const isDocsSelected = mentionActiveIndex === FOLDER_ORDER.length - if (isDocsSelected) { - resetActiveMentionQuery() - insertHandlers.insertDocsMention() - return true - } - - const selectedFolderId = FOLDER_ORDER[mentionActiveIndex] - if (selectedFolderId && mentionFolderNav) { - const config = FOLDER_CONFIGS[selectedFolderId] - resetActiveMentionQuery() - mentionFolderNav.openFolder(selectedFolderId, config.title) - setSubmenuActiveIndex(0) - setSubmenuQueryStart(getCaretPos()) - ensureFolderLoaded(selectedFolderId) - } - - return true - }, - [ - showMentionMenu, - isInFolder, - currentFolder, - mentionActiveIndex, - submenuActiveIndex, - mentionFolderNav, - buildAggregatedList, - filterFolderItems, - insertHandlerMap, - getCaretPos, - getActiveMentionQueryAtPosition, - getSubmenuQuery, - resetActiveMentionQuery, - setSubmenuActiveIndex, - setSubmenuQueryStart, - ensureFolderLoaded, - insertHandlers, - ] - ) - - return { - handleArrowNavigation, - handleArrowRight, - handleArrowLeft, - handleEnterSelection, - } -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-textarea-auto-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-textarea-auto-resize.ts deleted file mode 100644 index 82ee7107ec7..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-textarea-auto-resize.ts +++ /dev/null @@ -1,232 +0,0 @@ -'use client' - -import { type RefObject, useEffect, useLayoutEffect, useRef } from 'react' - -/** - * Maximum textarea height in pixels - */ -const MAX_TEXTAREA_HEIGHT = 120 - -interface UseTextareaAutoResizeProps { - /** Current message content */ - message: string - /** Width of the panel */ - panelWidth: number - /** Selected mention contexts */ - selectedContexts: any[] - /** External textarea ref to sync with */ - textareaRef: RefObject - /** Container ref for observing layout shifts */ - containerRef: HTMLDivElement | null -} - -/** - * Custom hook to auto-resize textarea and sync with overlay. - * Uses ResizeObserver for accurate, event-driven synchronization without arbitrary timeouts. - * - * @param props - Configuration object - * @returns Overlay ref for highlight rendering - */ -export function useTextareaAutoResize({ - message, - panelWidth, - selectedContexts, - textareaRef, - containerRef, -}: UseTextareaAutoResizeProps) { - const overlayRef = useRef(null) - const containerResizeObserverRef = useRef(null) - const textareaResizeObserverRef = useRef(null) - - /** - * Syncs all styles and dimensions between textarea and overlay. - * Called immediately when DOM changes are detected. - */ - const syncOverlayStyles = useRef(() => { - const textarea = textareaRef.current - const overlay = overlayRef.current - if (!textarea || !overlay || typeof window === 'undefined') return - - const styles = window.getComputedStyle(textarea) - - overlay.style.font = styles.font - overlay.style.fontSize = styles.fontSize - overlay.style.fontFamily = styles.fontFamily - overlay.style.fontWeight = styles.fontWeight - overlay.style.fontStyle = styles.fontStyle - overlay.style.fontVariant = styles.fontVariant - overlay.style.letterSpacing = styles.letterSpacing - overlay.style.lineHeight = styles.lineHeight - overlay.style.fontKerning = (styles as any).fontKerning ?? '' - overlay.style.fontFeatureSettings = (styles as any).fontFeatureSettings ?? '' - overlay.style.textRendering = (styles as any).textRendering ?? '' - ;(overlay.style as any).tabSize = (styles as any).tabSize ?? '' - ;(overlay.style as any).MozTabSize = (styles as any).MozTabSize ?? '' - overlay.style.textTransform = styles.textTransform - overlay.style.textIndent = styles.textIndent - - overlay.style.padding = styles.padding - overlay.style.paddingTop = styles.paddingTop - overlay.style.paddingRight = styles.paddingRight - overlay.style.paddingBottom = styles.paddingBottom - overlay.style.paddingLeft = styles.paddingLeft - overlay.style.margin = styles.margin - overlay.style.marginTop = styles.marginTop - overlay.style.marginRight = styles.marginRight - overlay.style.marginBottom = styles.marginBottom - overlay.style.marginLeft = styles.marginLeft - overlay.style.border = styles.border - overlay.style.borderWidth = styles.borderWidth - - overlay.style.whiteSpace = styles.whiteSpace - overlay.style.wordBreak = styles.wordBreak - overlay.style.wordWrap = styles.wordWrap - overlay.style.overflowWrap = styles.overflowWrap - overlay.style.textAlign = styles.textAlign - overlay.style.boxSizing = styles.boxSizing - overlay.style.borderRadius = styles.borderRadius - overlay.style.direction = styles.direction - overlay.style.hyphens = (styles as any).hyphens ?? '' - - const textareaWidth = textarea.clientWidth - const textareaHeight = textarea.clientHeight - - overlay.style.width = `${textareaWidth}px` - overlay.style.height = `${textareaHeight}px` - - const computedMaxHeight = styles.maxHeight - if (computedMaxHeight && computedMaxHeight !== 'none') { - overlay.style.maxHeight = computedMaxHeight - } - - overlay.scrollTop = textarea.scrollTop - overlay.scrollLeft = textarea.scrollLeft - }) - - /** - * Auto-resize textarea based on content. - * Uses useLayoutEffect to run synchronously AFTER DOM mutations but BEFORE browser paint. - * This ensures we sync after React commits changes to the DOM. - */ - useLayoutEffect(() => { - const textarea = textareaRef.current - const overlay = overlayRef.current - if (!textarea || !overlay) return - - const cursorPos = textarea.selectionStart ?? 0 - const isAtEnd = cursorPos === message.length - const wasScrolledToBottom = - textarea.scrollHeight - textarea.scrollTop - textarea.clientHeight < 5 - - textarea.style.height = 'auto' - overlay.style.height = 'auto' - - void textarea.offsetHeight - void overlay.offsetHeight - - const scrollHeight = textarea.scrollHeight - const nextHeight = Math.min(scrollHeight, MAX_TEXTAREA_HEIGHT) - - const heightString = `${nextHeight}px` - const overflowString = scrollHeight > MAX_TEXTAREA_HEIGHT ? 'auto' : 'hidden' - - textarea.style.height = heightString - textarea.style.overflowY = overflowString - overlay.style.height = heightString - overlay.style.overflowY = overflowString - - void textarea.offsetHeight - void overlay.offsetHeight - - if ((isAtEnd || wasScrolledToBottom) && scrollHeight > nextHeight) { - const scrollValue = scrollHeight - textarea.scrollTop = scrollValue - overlay.scrollTop = scrollValue - } else { - overlay.scrollTop = textarea.scrollTop - overlay.scrollLeft = textarea.scrollLeft - } - - syncOverlayStyles.current() - }, [message, selectedContexts, textareaRef]) - - /** - * Sync scroll position between textarea and overlay - */ - useEffect(() => { - const textarea = textareaRef.current - const overlay = overlayRef.current - - if (!textarea || !overlay) return - - const handleScroll = () => { - overlay.scrollTop = textarea.scrollTop - overlay.scrollLeft = textarea.scrollLeft - } - - textarea.addEventListener('scroll', handleScroll, { passive: true }) - return () => textarea.removeEventListener('scroll', handleScroll) - }, [textareaRef]) - - /** - * Setup ResizeObserver on the CONTAINER to catch layout shifts when pills wrap. - * This is critical because when pills wrap, the textarea moves but doesn't resize. - */ - useLayoutEffect(() => { - const textarea = textareaRef.current - const overlay = overlayRef.current - if (!textarea || !overlay || !containerRef || typeof window === 'undefined') return - - syncOverlayStyles.current() - - if (typeof ResizeObserver !== 'undefined' && !containerResizeObserverRef.current) { - containerResizeObserverRef.current = new ResizeObserver(() => { - syncOverlayStyles.current() - }) - containerResizeObserverRef.current.observe(containerRef) - } - - if (typeof ResizeObserver !== 'undefined' && !textareaResizeObserverRef.current) { - textareaResizeObserverRef.current = new ResizeObserver(() => { - syncOverlayStyles.current() - }) - textareaResizeObserverRef.current.observe(textarea) - } - - const mutationObserver = new MutationObserver(() => { - syncOverlayStyles.current() - }) - mutationObserver.observe(textarea, { - attributes: true, - attributeFilter: ['style', 'class'], - }) - - const handleResize = () => syncOverlayStyles.current() - window.addEventListener('resize', handleResize) - - return () => { - mutationObserver.disconnect() - window.removeEventListener('resize', handleResize) - } - }, [panelWidth, textareaRef, containerRef]) - - /** - * Cleanup ResizeObservers on unmount - */ - useEffect(() => { - return () => { - if (containerResizeObserverRef.current) { - containerResizeObserverRef.current.disconnect() - containerResizeObserverRef.current = null - } - if (textareaResizeObserverRef.current) { - textareaResizeObserverRef.current.disconnect() - textareaResizeObserverRef.current = null - } - } - }, []) - - return { - overlayRef, - } -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/types.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/types.ts deleted file mode 100644 index 5b1110c04b9..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { MentionFolderId } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants' - -/** - * Shared folder navigation state for the mention menu. - */ -export interface MentionFolderNav { - currentFolder: MentionFolderId | null - isInFolder: boolean - openFolder: (folderId: MentionFolderId, title: string) => void - closeFolder: () => void -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts index 3e8c4d8be5d..5c5c7382d41 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts @@ -1,9 +1,3 @@ -import type { ReactNode } from 'react' -import { - FOLDER_CONFIGS, - type MentionFolderId, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants' -import type { MentionDataReturn } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data' import type { ChatContext } from '@/stores/panel' /** @@ -123,79 +117,6 @@ export function computeMentionHighlightRanges( return ranges } -/** - * Builds React nodes with highlighted mention tokens - * @param text - Text to render - * @param contexts - Chat contexts to highlight - * @param createHighlightSpan - Function to create highlighted span element - * @returns Array of React nodes with highlighted mentions - */ -export function buildMentionHighlightNodes( - text: string, - contexts: ChatContext[], - createHighlightSpan: (token: string, key: string) => ReactNode -): ReactNode[] { - const tokens = extractContextTokens(contexts) - if (!tokens.length) return [text] - - const ranges = computeMentionHighlightRanges(text, tokens) - if (!ranges.length) return [text] - - const nodes: ReactNode[] = [] - let lastIndex = 0 - - for (const range of ranges) { - if (range.start > lastIndex) { - nodes.push(text.slice(lastIndex, range.start)) - } - nodes.push(createHighlightSpan(range.token, `mention-${range.start}-${range.end}`)) - lastIndex = range.end - } - - if (lastIndex < text.length) { - nodes.push(text.slice(lastIndex)) - } - - return nodes -} - -/** - * Gets the data array for a folder ID from mentionData. - * Uses FOLDER_CONFIGS as the source of truth for key mapping. - * Returns any[] since item types vary by folder and are used with dynamic config.filterFn - */ -export function getFolderData(mentionData: MentionDataReturn, folderId: MentionFolderId): any[] { - const config = FOLDER_CONFIGS[folderId] - return (mentionData[config.dataKey as keyof MentionDataReturn] as any[]) || [] -} - -/** - * Gets the loading state for a folder ID from mentionData. - * Uses FOLDER_CONFIGS as the source of truth for key mapping. - */ -export function getFolderLoading( - mentionData: MentionDataReturn, - folderId: MentionFolderId -): boolean { - const config = FOLDER_CONFIGS[folderId] - return mentionData[config.loadingKey as keyof MentionDataReturn] as boolean -} - -/** - * Gets the ensure loaded function for a folder ID from mentionData. - * Uses FOLDER_CONFIGS as the source of truth for key mapping. - */ -export function getFolderEnsureLoaded( - mentionData: MentionDataReturn, - folderId: MentionFolderId -): (() => Promise) | undefined { - const config = FOLDER_CONFIGS[folderId] - if (!config.ensureLoadedKey) return undefined - return mentionData[config.ensureLoadedKey as keyof MentionDataReturn] as - | (() => Promise) - | undefined -} - /** * Extract specific ChatContext types for type-safe narrowing */ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index bd1cd4d6114..21f96178810 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -14,6 +14,7 @@ import { } from '@/lib/oauth' import { getMissingRequiredScopes, getServiceConfigByServiceId } from '@/lib/oauth/utils' import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' +import { ProviderIcon } from '@/app/workspace/[workspaceId]/components/provider-icon' import { ConnectServiceAccountModal, type ServiceAccountProviderId, @@ -24,7 +25,6 @@ import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' -import { getBareIconStyle, type StyleableIcon } from '@/blocks/brand-icon-style' import type { SubBlockConfig } from '@/blocks/types' import { useWorkspaceCredential, useWorkspaceCredentials } from '@/hooks/queries/credentials' import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials' @@ -226,16 +226,10 @@ export function CredentialSelector({ setShowConnectModal(true) }, [credentialKind]) - const getProviderIcon = useCallback((providerName: OAuthProvider) => { - const { baseProvider } = parseProvider(providerName) - const baseProviderConfig = OAUTH_PROVIDERS[baseProvider] - - if (!baseProviderConfig) { - return - } - const Icon: StyleableIcon = baseProviderConfig.icon - return - }, []) + const getProviderIcon = useCallback( + (providerName: OAuthProvider) => , + [] + ) const getProviderName = useCallback((providerName: OAuthProvider) => { const { baseProvider } = parseProvider(providerName) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx index bce4c0b2ede..ae2e569d22e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx @@ -29,6 +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 { VARIABLE_TILE_COLOR } from '@/blocks/accent' import { BlockTile } from '@/blocks/block-tile' import type { BlockConfig } from '@/blocks/types' import { normalizeName } from '@/executor/constants' @@ -154,16 +155,6 @@ export const getTagSearchTerm = (text: string, cursorPosition: number): string = return textBeforeCursor.slice(lastOpenBracket + 1).toLowerCase() } -/** - * Color constants for block type icons in the tag dropdown. - */ -const BLOCK_COLORS = { - VARIABLE: '#2F8BFF', - DEFAULT: '#2F55FF', - LOOP: '#2FB3FF', - PARALLEL: '#FEE12B', -} as const - /** * Prefix constants for special tag types. */ @@ -1709,7 +1700,7 @@ export const TagDropdown: React.FC = ({ <> - + Variables diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx index aa51770bbed..3cc20f64a39 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx @@ -1,8 +1,7 @@ 'use client' -import { createElement, useCallback, useMemo, useRef, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { Button, Combobox } from '@sim/emcn' -import { SquareArrowUpRight } from '@sim/emcn/icons' import { useParams } from 'next/navigation' import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state' import { @@ -16,6 +15,7 @@ import { } from '@/lib/oauth' import { getMissingRequiredScopes } from '@/lib/oauth/utils' import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' +import { ProviderIcon } from '@/app/workspace/[workspaceId]/components/provider-icon' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' @@ -25,15 +25,9 @@ import { useWorkflowMap } from '@/hooks/queries/workflows' import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -const getProviderIcon = (providerName: OAuthProvider) => { - const { baseProvider } = parseProvider(providerName) - const baseProviderConfig = OAUTH_PROVIDERS[baseProvider] - - if (!baseProviderConfig) { - return - } - return createElement(baseProviderConfig.icon, { className: 'size-3' }) -} +const getProviderIcon = (providerName: OAuthProvider) => ( + +) const getProviderName = (providerName: OAuthProvider) => { const serviceConfig = getServiceConfigByProviderId(providerName) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx index cffd670abbf..09360245f96 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx @@ -33,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 { DEFAULT_BLOCK_TILE_COLOR } from '@/blocks/accent' import { BlockTile } from '@/blocks/block-tile' import { buildCustomBlockConfig, @@ -89,7 +90,7 @@ const ToolbarItem = memo(function ToolbarItem({ const iconContainer = e.currentTarget.querySelector('[data-toolbar-item-icon]') onDragStart(e, item.type, isTriggerCapable, { name: item.name, - bgColor: item.bgColor ?? '#666666', + bgColor: item.bgColor ?? DEFAULT_BLOCK_TILE_COLOR, iconContainer, }) }, 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 c8c04655e6d..067bc7bfc86 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,7 @@ import type React from 'react' import { Ban, CircleX, Repeat, Split, TriangleAlert, Workflow } from '@sim/emcn/icons' import { getBlock } from '@/blocks' +import { DEFAULT_BLOCK_TILE_COLOR } from '@/blocks/accent' import { isWorkflowBlockType } from '@/executor/constants' import { TERMINAL_BLOCK_COLUMN_WIDTH } from '@/stores/constants' import type { ConsoleEntry } from '@/stores/terminal' @@ -20,7 +21,7 @@ const SUBFLOW_COLORS = { const SPECIAL_BLOCK_COLORS = { error: '#ef4444', validation: '#f59e0b', - cancelled: '#6b7280', + cancelled: DEFAULT_BLOCK_TILE_COLOR, } as const /** @@ -90,7 +91,7 @@ export function getBlockColor(blockType: string): string { if (blockType === 'cancelled') { return SPECIAL_BLOCK_COLORS.cancelled } - return '#6b7280' + return DEFAULT_BLOCK_TILE_COLOR } /** 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 e7dd73f90ce..47c6ab67306 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 @@ -43,6 +43,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 { DEFAULT_BLOCK_TILE_COLOR, VARIABLE_TILE_COLOR } from '@/blocks/accent' import { BlockTile } from '@/blocks/block-tile' import type { BlockConfig, SubBlockConfig, SubBlockType } from '@/blocks/types' import { normalizeName } from '@/executor/constants' @@ -436,9 +437,7 @@ function ConnectionsSection({ handleKeyboardActivation(event, () => setExpandedVariables(!expandedVariables)) } > - - V - + setExpandedEnvVars(!expandedEnvVars)) } > - - E - + onToggleBlock()} /> - - {BlockIcon && } - + isBlockAllowed && isExpandable && setExpanded((prev) => !prev)} @@ -1780,12 +1776,11 @@ export function GroupDetail({ checked={isIntegrationAllowed(block.type)} onCheckedChange={() => toggleIntegration(block.type)} /> - - {BlockIcon && } - + {block.name} {block.description && ( diff --git a/apps/sim/lib/integrations/index.ts b/apps/sim/lib/integrations/index.ts index 97228c7cf69..e72062c6fa2 100644 --- a/apps/sim/lib/integrations/index.ts +++ b/apps/sim/lib/integrations/index.ts @@ -66,6 +66,7 @@ export { export { blockTypeToIconMap } from '@/lib/integrations/icon-mapping' export { type OAuthServiceMatch, + resolveIntegrationBlockTypeForOAuth, resolveOAuthServiceForIntegration, resolveOAuthServiceForSlug, } from '@/lib/integrations/oauth-service' diff --git a/apps/sim/lib/integrations/oauth-service.test.ts b/apps/sim/lib/integrations/oauth-service.test.ts index dbd70af7c58..2520c7fb54b 100644 --- a/apps/sim/lib/integrations/oauth-service.test.ts +++ b/apps/sim/lib/integrations/oauth-service.test.ts @@ -1,13 +1,16 @@ /** * @vitest-environment node */ +import { stripVersionSuffix } from '@sim/utils/string' import { describe, expect, it } from 'vitest' import integrationsJson from '@/lib/integrations/integrations.json' import { + resolveIntegrationBlockTypeForOAuth, resolveOAuthServiceForSlug, resolveServiceAccountIntegration, } from '@/lib/integrations/oauth-service' import type { Integration } from '@/lib/integrations/types' +import { getBlockTileColor, getBlockTileIcon } from '@/blocks/accent' const INTEGRATIONS = integrationsJson.integrations as readonly Integration[] @@ -181,3 +184,94 @@ describe('resolveServiceAccountIntegration', () => { expect(resolveServiceAccountIntegration(' ')).toBeNull() }) }) + +/** + * Integrations whose `oauthServiceId` is shared with a sibling, so the id names + * a pair rather than a block: Google Slides rides Drive's service, Jira Service + * Management rides Jira's. The bridge deliberately resolves none of them. + */ +const SHARED_OAUTH_ID_SLUGS = ['google-drive', 'google-slides', 'jira', 'jira-service-management'] + +/** Resolved block type with any version suffix dropped, for stable assertions. */ +function baseTypeFor(...keys: (string | undefined)[]): string | undefined { + const blockType = resolveIntegrationBlockTypeForOAuth(...keys) + return blockType ? stripVersionSuffix(blockType) : undefined +} + +describe('resolveIntegrationBlockTypeForOAuth', () => { + it.concurrent('resolves a service id, a provider id, and an extra auth server', () => { + // Each is a distinct key shape a credential surface can be holding: the + // service id a block declares, the provider id that service registers, and + // the second authorization server Salesforce accepts. The catalog carries + // versioned types (`gmail_v2`), so compare on the base — the version that + // wins is whichever the catalog lists first and is not the contract here. + expect(baseTypeFor('gmail')).toBe('gmail') + expect(baseTypeFor('google-email')).toBe('gmail') + expect(baseTypeFor('salesforce-sandbox')).toBe('salesforce') + }) + + it.concurrent('is case-insensitive and skips empty keys', () => { + expect(baseTypeFor('GOOGLE-EMAIL')).toBe('gmail') + expect(resolveIntegrationBlockTypeForOAuth(undefined, '', 'slack')).toBeDefined() + }) + + it.concurrent('takes the first key that resolves, so callers can order by specificity', () => { + // A connect dialog passes the service id before the provider id it derives + // from; the specific one has to win or every Google service would render + // whichever member the catalog happens to list first. + expect(baseTypeFor('google-sheets', 'google-email')).toBe('google_sheets') + expect(baseTypeFor('unknown-service', 'google-email')).toBe('gmail') + }) + + it.concurrent('returns undefined for an id no catalog integration claims', () => { + // The signal to keep the caller's existing mark rather than invent one. + expect(resolveIntegrationBlockTypeForOAuth('not-a-real-service')).toBeUndefined() + expect(resolveIntegrationBlockTypeForOAuth()).toBeUndefined() + expect(resolveIntegrationBlockTypeForOAuth(undefined, '')).toBeUndefined() + }) + + it.concurrent('refuses to guess when one OAuth id names more than one block', () => { + // Google Slides is authenticated by Drive's service and JSM by Jira's, so + // these ids name a pair. Answering with either member would put the wrong + // brand on the other's connect dialog, so the bridge declines. + expect(resolveIntegrationBlockTypeForOAuth('google-drive')).toBeUndefined() + expect(resolveIntegrationBlockTypeForOAuth('jira')).toBeUndefined() + }) + + it.concurrent('resolves every OAuth integration whose id names it alone', () => { + // A credential surface that cannot reach a block type falls back to the + // colourless OAUTH_PROVIDERS mark, which is the bug this bridge exists to + // close — so every unambiguous integration must be in the index, and the + // only permitted misses are the shared ids above. + const unresolved = INTEGRATIONS.filter( + (integration) => + integration.authType === 'oauth' && + integration.oauthServiceId && + !resolveIntegrationBlockTypeForOAuth(integration.oauthServiceId) + ).map((integration) => integration.slug) + + expect(unresolved.sort()).toEqual(SHARED_OAUTH_ID_SLUGS) + }) +}) + +describe('the OAuth bridge reaches a renderable tile', () => { + it('resolves every OAuth integration to a block carrying both an icon and a fill', () => { + // The bridge exists so a credential surface can draw the block's tile. + // A type that resolves but has no registered icon or colour would paint an + // empty square in the connect dialog — worse than the grey mark it replaced. + const broken = INTEGRATIONS.filter( + (integration) => + integration.authType === 'oauth' && + integration.oauthServiceId && + !SHARED_OAUTH_ID_SLUGS.includes(integration.slug) + ).flatMap((integration) => { + const blockType = resolveIntegrationBlockTypeForOAuth(integration.oauthServiceId) + if (!blockType) return [`${integration.slug}: unresolved`] + if (!getBlockTileIcon(blockType)) return [`${integration.slug} -> ${blockType}: no icon`] + if (!getBlockTileColor(blockType)) return [`${integration.slug} -> ${blockType}: no fill`] + return [] + }) + + expect(broken).toEqual([]) + }) +}) diff --git a/apps/sim/lib/integrations/oauth-service.ts b/apps/sim/lib/integrations/oauth-service.ts index d952c61f678..24d18f895c8 100644 --- a/apps/sim/lib/integrations/oauth-service.ts +++ b/apps/sim/lib/integrations/oauth-service.ts @@ -60,6 +60,72 @@ export function resolveOAuthServiceForSlug(slug: string): OAuthServiceMatch | nu return resolveOAuthServiceForIntegration(integration) } +/** + * Catalog block type for every OAuth key that can name it — the service id its + * block declares, the provider id that service registers, and any additional + * authorization servers it accepts (Salesforce sandbox). + * + * Credential surfaces are handed an OAuth identity, not a block type, which is + * why they historically rendered a bare mark from `OAUTH_PROVIDERS` — the only + * registry they could reach. That map carries an icon and no colour, so the + * same service showed its brand on the canvas and a flat grey in a connect + * dialog. This is the bridge back: given any OAuth id, the block whose config + * owns the icon and `bgColor`. + * + * A key two integrations both claim resolves to neither. Google Slides is + * authenticated by Drive's `google-drive` service and Jira Service Management + * by Jira's `jira`, so those ids name a pair, not a block — and picking the + * one that happens to sort first would put Drive's tile on the dialog opening + * Slides. Dropping the key falls the caller back to the service-specific mark + * it already had, which is the honest answer: no wrong brand. + */ +const BLOCK_TYPE_BY_OAUTH_KEY: ReadonlyMap = (() => { + const claims = new Map>() + const claim = (key: string | undefined, blockType: string) => { + if (!key) return + const normalized = key.toLowerCase() + const owners = claims.get(normalized) + if (owners) owners.add(blockType) + else claims.set(normalized, new Set([blockType])) + } + + for (const integration of INTEGRATIONS_DATA) { + if (integration.authType !== 'oauth' || !integration.oauthServiceId) continue + const service = getServiceConfigByServiceId(integration.oauthServiceId) + if (!service) continue + claim(integration.oauthServiceId, integration.type) + claim(service.providerId, integration.type) + for (const extraProviderId of service.additionalProviderIds ?? []) { + claim(extraProviderId, integration.type) + } + } + + const index = new Map() + for (const [key, owners] of claims) { + if (owners.size === 1) index.set(key, owners.values().next().value as string) + } + return index +})() + +/** + * Block type behind an OAuth identity, so a credential surface can render the + * same tile the canvas does. Keys are tried in order — pass the most specific + * first (a service id before the provider id it resolves to). + * + * Returns `undefined` when no catalog integration claims the id, which is the + * signal to keep whatever mark the caller already had rather than invent one. + */ +export function resolveIntegrationBlockTypeForOAuth( + ...keys: readonly (string | undefined)[] +): string | undefined { + for (const key of keys) { + if (!key) continue + const blockType = BLOCK_TYPE_BY_OAUTH_KEY.get(key.toLowerCase()) + if (blockType) return blockType + } + return undefined +} + /** * An integration that exposes a service-account connect flow, resolved to the * catalog slug whose detail page mounts `ConnectServiceAccountModal`. diff --git a/packages/emcn/src/components/chip-modal/chip-modal.tsx b/packages/emcn/src/components/chip-modal/chip-modal.tsx index 4909e62009b..1b1f82f16a4 100644 --- a/packages/emcn/src/components/chip-modal/chip-modal.tsx +++ b/packages/emcn/src/components/chip-modal/chip-modal.tsx @@ -165,8 +165,14 @@ function ChipModal({ ChipModal.displayName = 'ChipModal' export interface ChipModalHeaderProps extends React.HTMLAttributes { - /** Optional leading icon. Pass `null`/omit for a title-only header. */ - icon?: React.ComponentType<{ className?: string }> | null + /** + * Optional leading icon. Pass `null`/omit for a title-only header. + * + * A component is drawn in the header's own icon colour; pass a rendered + * element instead when the mark carries its own chrome — a brand tile owns + * its fill and contrast, and tinting it grey would be wrong. + */ + icon?: React.ComponentType<{ className?: string }> | React.ReactElement | null /** Invoked when the trailing close button is activated. Always rendered. */ onClose: () => void /** @@ -201,7 +207,11 @@ const ChipModalHeader = React.forwardRef( - {Icon ? : null} + {React.isValidElement(Icon) ? ( + Icon + ) : Icon ? ( + + ) : null} {children}
{integration.description}