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 96b2c2f8e3d..a5bd8cdf51a 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,7 +19,6 @@ 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, @@ -27,7 +26,6 @@ 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' @@ -175,20 +173,6 @@ 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, @@ -359,7 +343,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 deleted file mode 100644 index f3206a41318..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/components/provider-icon/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index dd5f3bbdd55..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/components/provider-icon/provider-icon.tsx +++ /dev/null @@ -1,35 +0,0 @@ -'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 8dd8d3aad39..6f8653d1611 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,6 +258,7 @@ 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 49f0be5e844..8641b9b5eab 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,6 +7,7 @@ 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, @@ -15,7 +16,6 @@ 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,16 +224,6 @@ 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 @@ -241,20 +231,20 @@ const INITIAL_INTEGRATIONS = [ * before the personalized recompute replaces it. */ const INITIAL_ACTIONS: Action[] = [ - ...INITIAL_INTEGRATIONS.flatMap(({ blockType, slug, name }) => { - const icon = getBlockTileIcon(blockType) - return icon - ? [ - { - kind: 'integration', - id: `integrate-${slug}`, - label: `Integrate with ${name}`, - icon, - slug, - }, - ] - : [] - }), + { + 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', + }, 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 2a2cbf9dd23..f8fbafb5e47 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,12 +8,14 @@ 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 { @@ -32,7 +34,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 { getBlockTileIcon } from '@/blocks/accent' +import { getTileIconColorClass } from '@/blocks/icon-color' import { storeCuratedPrompt } from '@/blocks/integration-matcher' import { getSuggestedSkillsForBlock, @@ -62,6 +64,7 @@ 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) @@ -230,10 +233,16 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration > - + {Icon ? ( + + ) : ( + + {integration.name.charAt(0)} + + )} {integration.name} {integration.description} @@ -246,7 +255,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration } + icon={Icon && } title={credential.displayName} description={ credential.description || resolveCredentialDisplay(credential).subtitle @@ -365,7 +374,8 @@ function TemplateIcons({ blockTypes }: TemplateIconsProps) { return ( {blockTypes.map((bt, idx) => { - if (!getBlockTileIcon(bt)) return null + const ToolIcon = blockTypeToIconMap[bt] + if (!ToolIcon) return null const z = TEMPLATE_TILE_Z[idx] if (!z) return null const isTrailing = idx > 0 @@ -379,7 +389,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 e4b29e32451..43a25c08ba1 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,7 +5,6 @@ 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' /** @@ -60,15 +59,7 @@ function resolveBrandTileBg(blockType: string): string | null { interface IntegrationTileProps { blockType: 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 + icon: ComponentType<{ className?: string }> framed?: boolean } @@ -77,23 +68,16 @@ 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, - fallbackLabel, - framed = false, -}: IntegrationTileProps) { +export function IntegrationTile({ blockType, icon: Icon, framed = false }: IntegrationTileProps) { const brandBg = resolveBrandTileBg(blockType) - const Icon = icon ?? getBlockTileIcon(blockType) - const contentClass = getTileIconColorClass(brandBg) if (!framed) { return ( - {Icon ? : fallbackLabel} + ) } @@ -101,13 +85,10 @@ export function IntegrationTile({ 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 705f6647dfd..2b1ae99f392 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,6 +8,7 @@ import { ChipInput, ChipLink, ChipTextarea, + cn, Send, toast, } from '@sim/emcn' @@ -27,6 +28,10 @@ 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, @@ -239,11 +244,15 @@ export function ConnectedCredentialDetail({ + display?.icon ? ( + + ) : ( + + + {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 3f26e43d390..9f166f59e8b 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx @@ -14,6 +14,7 @@ import { import { useParams } from 'next/navigation' import { useQueryStates } from 'nuqs' import { + blockTypeToIconMap, formatIntegrationType, INTEGRATIONS, type Integration, @@ -33,7 +34,6 @@ 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,6 +68,7 @@ interface IntegrationItemProps { workspaceId: string name: string description?: string | null + icon: ComponentType<{ className?: string }> unavailable?: boolean } @@ -77,12 +78,13 @@ function IntegrationItem({ workspaceId, name, description, + icon: Icon, unavailable = false, }: IntegrationItemProps) { return ( } + icon={} title={name} description={ unavailable @@ -346,7 +348,8 @@ export function Integrations() { {filteredCategorySections.map((section) => ( {section.integrations.map((integration) => { - if (!getBlockTileIcon(integration.type)) return null + const Icon = blockTypeToIconMap[integration.type] + if (!Icon) return null const availability = integrationAvailability.get(integration.type.toLowerCase()) const deploymentUnavailable = availability?.state === 'unavailable' || availability?.state === 'misconfigured' @@ -358,6 +361,7 @@ 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 38b6ca8ab26..a547065ff13 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,6 +16,7 @@ import { ChipModalFooter, ChipModalHeader, type ComboboxOption, + cn, handleKeyboardActivation, Search, } from '@sim/emcn' @@ -30,11 +31,12 @@ 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' @@ -477,6 +479,9 @@ 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 deleted file mode 100644 index b3a899c3728..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-tile/connector-tile.tsx +++ /dev/null @@ -1,44 +0,0 @@ -'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 deleted file mode 100644 index 5250cae67e9..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-tile/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 baf4d9b46e0..8db6e3e7c87 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,8 +32,9 @@ 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 { @@ -308,6 +309,7 @@ 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 @@ -357,7 +359,24 @@ 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 7ac4eb889de..b3dc95416bb 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,7 +7,6 @@ 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' @@ -25,6 +24,8 @@ 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 @@ -70,12 +71,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_TILE_COLOR } + return { icon: providerDef.icon, bgColor: providerDef.color ?? DEFAULT_BLOCK_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_TILE_COLOR } + return { icon: null, bgColor: DEFAULT_BLOCK_COLOR } } /** @@ -92,9 +93,7 @@ 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_TILE_COLOR - : bgColor + return brightness !== null && brightness < 30_000 / MAX_YIQ_SUM ? DEFAULT_BLOCK_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 a61e953328c..af5cceea88c 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,13 +1,9 @@ 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 62fac4b055f..be473ae71d3 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 } + ? { icon: blockConfig.icon, color: blockConfig.bgColor || '#2F55FF' } : 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 bb894543814..d9cdf9702ac 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,4 +1,266 @@ +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 f17c6d0102e..254349f067b 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,5 +1,10 @@ +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 new file mode 100644 index 00000000000..51cc9212289 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-caret-viewport.ts @@ -0,0 +1,77 @@ +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 new file mode 100644 index 00000000000..2736c964f75 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts @@ -0,0 +1,365 @@ +'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 new file mode 100644 index 00000000000..75eb4f7ec50 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-insert-handlers.ts @@ -0,0 +1,137 @@ +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 new file mode 100644 index 00000000000..8ab898483ff --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-keyboard.ts @@ -0,0 +1,355 @@ +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 new file mode 100644 index 00000000000..82ee7107ec7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-textarea-auto-resize.ts @@ -0,0 +1,232 @@ +'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 new file mode 100644 index 00000000000..5b1110c04b9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/types.ts @@ -0,0 +1,11 @@ +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 5c5c7382d41..3e8c4d8be5d 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,3 +1,9 @@ +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' /** @@ -117,6 +123,79 @@ 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 21f96178810..bd1cd4d6114 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,7 +14,6 @@ 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, @@ -25,6 +24,7 @@ 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,10 +226,16 @@ export function CredentialSelector({ setShowConnectModal(true) }, [credentialKind]) - const getProviderIcon = useCallback( - (providerName: OAuthProvider) => , - [] - ) + const getProviderIcon = useCallback((providerName: OAuthProvider) => { + const { baseProvider } = parseProvider(providerName) + const baseProviderConfig = OAUTH_PROVIDERS[baseProvider] + + if (!baseProviderConfig) { + return + } + const Icon: StyleableIcon = baseProviderConfig.icon + return + }, []) 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 ae2e569d22e..bce4c0b2ede 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,7 +29,6 @@ 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' @@ -155,6 +154,16 @@ 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. */ @@ -1700,7 +1709,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 3cc20f64a39..aa51770bbed 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,7 +1,8 @@ 'use client' -import { useCallback, useMemo, useRef, useState } from 'react' +import { createElement, 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 { @@ -15,7 +16,6 @@ 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,9 +25,15 @@ 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 getProviderIcon = (providerName: OAuthProvider) => { + const { baseProvider } = parseProvider(providerName) + const baseProviderConfig = OAUTH_PROVIDERS[baseProvider] + + if (!baseProviderConfig) { + return + } + return createElement(baseProviderConfig.icon, { className: 'size-3' }) +} 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 09360245f96..cffd670abbf 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,7 +33,6 @@ 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, @@ -90,7 +89,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 ?? DEFAULT_BLOCK_TILE_COLOR, + bgColor: item.bgColor ?? '#666666', 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 067bc7bfc86..c8c04655e6d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts @@ -1,7 +1,6 @@ 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' @@ -21,7 +20,7 @@ const SUBFLOW_COLORS = { const SPECIAL_BLOCK_COLORS = { error: '#ef4444', validation: '#f59e0b', - cancelled: DEFAULT_BLOCK_TILE_COLOR, + cancelled: '#6b7280', } as const /** @@ -91,7 +90,7 @@ export function getBlockColor(blockType: string): string { if (blockType === 'cancelled') { return SPECIAL_BLOCK_COLORS.cancelled } - return DEFAULT_BLOCK_TILE_COLOR + return '#6b7280' } /** 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 47c6ab67306..e7dd73f90ce 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,7 +43,6 @@ 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' @@ -437,7 +436,9 @@ function ConnectionsSection({ handleKeyboardActivation(event, () => setExpandedVariables(!expandedVariables)) } > - + + V + setExpandedEnvVars(!expandedEnvVars)) } > - + + E + onToggleBlock()} /> - + + {BlockIcon && } + isBlockAllowed && isExpandable && setExpanded((prev) => !prev)} @@ -1776,11 +1780,12 @@ 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 e72062c6fa2..97228c7cf69 100644 --- a/apps/sim/lib/integrations/index.ts +++ b/apps/sim/lib/integrations/index.ts @@ -66,7 +66,6 @@ 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 2520c7fb54b..dbd70af7c58 100644 --- a/apps/sim/lib/integrations/oauth-service.test.ts +++ b/apps/sim/lib/integrations/oauth-service.test.ts @@ -1,16 +1,13 @@ /** * @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[] @@ -184,94 +181,3 @@ 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 24d18f895c8..d952c61f678 100644 --- a/apps/sim/lib/integrations/oauth-service.ts +++ b/apps/sim/lib/integrations/oauth-service.ts @@ -60,72 +60,6 @@ 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 1b1f82f16a4..4909e62009b 100644 --- a/packages/emcn/src/components/chip-modal/chip-modal.tsx +++ b/packages/emcn/src/components/chip-modal/chip-modal.tsx @@ -165,14 +165,8 @@ function ChipModal({ ChipModal.displayName = 'ChipModal' export interface ChipModalHeaderProps extends React.HTMLAttributes { - /** - * 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 + /** Optional leading icon. Pass `null`/omit for a title-only header. */ + icon?: React.ComponentType<{ className?: string }> | null /** Invoked when the trailing close button is activated. Always rendered. */ onClose: () => void /** @@ -207,11 +201,7 @@ const ChipModalHeader = React.forwardRef( - {React.isValidElement(Icon) ? ( - Icon - ) : Icon ? ( - - ) : null} + {Icon ? : null} {children}
{integration.description}