Revert "fix(integrations): read every service mark from one registry (#6682)" (#6707)

This reverts commit b7a5f22abd.
This commit is contained in:
Waleed
2026-08-14 14:40:58 -07:00
committed by GitHub
parent b7a5f22abd
commit 9664e7ec17
37 changed files with 1692 additions and 388 deletions
@@ -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 ? <BlockTile blockType={blockType} size='md' /> : 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 (
<ChipModal open={open} onOpenChange={onOpenChange} srTitle={title}>
<ChipModalHeader icon={headerIcon} onClose={handleClose}>
<ChipModalHeader icon={ProviderIcon} onClose={handleClose}>
{title}
</ChipModalHeader>
<ChipModalBody>
@@ -1 +0,0 @@
export { ProviderIcon } from './provider-icon'
@@ -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 <SquareArrowUpRight className={className} />
const Icon = config.icon as StyleableIcon
return (
<Icon className={cn('text-[var(--text-icon)]', className)} style={getBareIconStyle(Icon)} />
)
}
@@ -258,6 +258,7 @@ export function useAvailableResources(
id: integration.blockType,
name: integration.name,
iconComponent: integration.icon,
bgColor: integration.bgColor,
})),
},
{
@@ -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<Action>(({ 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)
@@ -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
>
<div className='mx-auto flex max-w-[48rem] flex-col gap-7 pb-3'>
<div className='flex flex-col gap-3'>
<IntegrationTile
blockType={integration.type}
fallbackLabel={integration.name.charAt(0)}
/>
{Icon ? (
<IntegrationTile blockType={integration.type} icon={Icon} />
) : (
<div
className={cn(RESOURCE_TILE_BASE, getTileIconColorClass(integration.bgColor))}
style={{ background: integration.bgColor }}
>
{integration.name.charAt(0)}
</div>
)}
<div className='flex flex-col gap-1'>
<h1 className='text-[var(--text-body)] text-lg'>{integration.name}</h1>
<p className='text-[var(--text-muted)] text-md'>{integration.description}</p>
@@ -246,7 +255,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
<SettingsResourceRow
key={credential.id}
iconVariant='custom'
icon={<IntegrationTile blockType={integration.type} />}
icon={Icon && <IntegrationTile blockType={integration.type} icon={Icon} />}
title={credential.displayName}
description={
credential.description || resolveCredentialDisplay(credential).subtitle
@@ -365,7 +374,8 @@ function TemplateIcons({ blockTypes }: TemplateIconsProps) {
return (
<span aria-hidden className='flex items-center'>
{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)]'
)}
>
<IntegrationTile blockType={bt} />
<IntegrationTile blockType={bt} icon={ToolIcon} />
</span>
)
})}
@@ -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 (
<div
className={cn(RESOURCE_TILE_BASE, RESOURCE_TILE_PLAIN, !Icon && contentClass)}
className={cn(RESOURCE_TILE_BASE, RESOURCE_TILE_PLAIN)}
style={brandBg ? { background: brandBg } : undefined}
>
{Icon ? <Icon className={contentClass} /> : fallbackLabel}
<Icon className={getTileIconColorClass(brandBg)} />
</div>
)
}
@@ -101,13 +85,10 @@ export function IntegrationTile({
return (
<div className='size-11 flex-shrink-0 rounded-xl border border-[var(--border-muted)] bg-[var(--surface-4)] p-[3px] shadow-sm dark:bg-[var(--surface-5)]'>
<div
className={cn(
'flex size-full items-center justify-center rounded-[9px] border border-[var(--border-1)] bg-[var(--bg)]',
!Icon && contentClass
)}
className='flex size-full items-center justify-center rounded-[9px] border border-[var(--border-1)] bg-[var(--bg)]'
style={brandBg ? { background: brandBg } : undefined}
>
{Icon ? <Icon className={cn('size-6', contentClass)} /> : fallbackLabel}
<Icon className={cn('size-6', getTileIconColorClass(brandBg))} />
</div>
</div>
)
@@ -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({
<CredentialDetailLayout back={back} actions={actions}>
<CredentialDetailHeading
leading={
<IntegrationTile
blockType={integrationBlockType}
icon={display?.icon ?? undefined}
fallbackLabel={resolveProviderLabel(credential.providerId).slice(0, 1) || '?'}
/>
display?.icon ? (
<IntegrationTile blockType={integrationBlockType} icon={display.icon} />
) : (
<div className={cn(RESOURCE_TILE_BASE, RESOURCE_TILE_PLAIN)}>
<span className='text-[var(--text-tertiary)] text-small'>
{resolveProviderLabel(credential.providerId).slice(0, 1) || '?'}
</span>
</div>
)
}
title={headingTitle}
subtitle={display?.detailSubtitle ?? 'Connected service'}
@@ -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 (
<SettingsResourceRow
iconVariant='custom'
icon={<IntegrationTile blockType={blockType} />}
icon={<IntegrationTile blockType={blockType} icon={Icon} />}
title={name}
description={
unavailable
@@ -346,7 +348,8 @@ export function Integrations() {
{filteredCategorySections.map((section) => (
<IntegrationSection key={section.label} label={formatIntegrationType(section.label)}>
{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}
/>
)
@@ -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 (
<button
type='button'
@@ -484,7 +489,22 @@ function ConnectorTypeCard({ type, config, onClick }: ConnectorTypeCardProps) {
onClick={onClick}
>
<div className='size-9 flex-shrink-0'>
<ConnectorTile connectorType={type} icon={config.icon} />
<div
className={cn(
'flex size-full items-center justify-center rounded-xl border',
brandBg
? 'border-[var(--border-1)]'
: 'border-[var(--border-muted)] bg-[var(--surface-4)]'
)}
style={brandBg ? { background: brandBg } : undefined}
>
<Icon
className={cn(
'size-5',
brandBg ? getTileIconColorClass(brandBg) : 'text-[var(--text-icon)]'
)}
/>
</div>
</div>
<div className='flex min-w-0 flex-1 flex-col'>
<span className='truncate text-[var(--text-body)] text-sm'>{config.name}</span>
@@ -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 (
<div
className={cn(
'flex size-full items-center justify-center rounded-xl border',
brandBg ? 'border-[var(--border-1)]' : 'border-[var(--border-muted)] bg-[var(--surface-4)]'
)}
style={brandBg ? { background: brandBg } : undefined}
>
{Icon && (
<Icon
className={cn(
'size-5',
brandBg ? getTileIconColorClass(brandBg) : 'text-[var(--text-icon)]'
)}
/>
)}
</div>
)
}
@@ -1 +0,0 @@
export { ConnectorTile } from './connector-tile'
@@ -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({
<div className='flex items-center justify-between gap-2 px-2 py-2'>
<div className='flex min-w-0 items-center gap-2.5'>
<div className='relative size-9 flex-shrink-0'>
<ConnectorTile connectorType={connector.connectorType} icon={Icon} />
<div
className={cn(
'flex size-full items-center justify-center rounded-xl border',
brandBg
? 'border-[var(--border-1)]'
: 'border-[var(--border-muted)] bg-[var(--surface-4)]'
)}
style={brandBg ? { background: brandBg } : undefined}
>
{Icon && (
<Icon
className={cn(
'size-5',
brandBg ? getTileIconColorClass(brandBg) : 'text-[var(--text-icon)]'
)}
/>
)}
</div>
{connector.status === 'disabled' && (
<TriangleAlert className='-right-0.5 -top-0.5 absolute size-3 text-[var(--caution)]' />
)}
@@ -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 {
@@ -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 {
@@ -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
@@ -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<TItem = any> {
/** 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<MentionFolderId, FolderConfig> = {
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<string, string> = {
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
}
@@ -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'
@@ -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<HTMLTextAreaElement | null>
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])
}
@@ -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<void>
ensureKnowledgeLoaded: () => Promise<void>
ensureBlocksLoaded: () => Promise<void>
ensureLogsLoaded: () => Promise<void>
}
/**
* 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<PastChat[]>([])
const [isLoadingPastChats, setIsLoadingPastChats] = useState(false)
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeItem[]>([])
const [isLoadingKnowledge, setIsLoadingKnowledge] = useState(false)
const [blocksList, setBlocksList] = useState<BlockItem[]>([])
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<LogItem[]>([])
const [isLoadingLogs, setIsLoadingLogs] = useState(false)
const [workflowBlocks, setWorkflowBlocks] = useState<WorkflowBlockItem[]>([])
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,
}
}
@@ -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<typeof useMentionMenu>
/** 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(
<TItem>(config: FolderConfig<TItem>) => {
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
}
@@ -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<typeof useMentionMenu>
/** Mention data hook instance */
mentionData: ReturnType<typeof useMentionData>
/** 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<MentionFolderId, (item: any) => 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<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>) => {
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,
}
}
@@ -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<HTMLTextAreaElement | null>
/** 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<HTMLDivElement>(null)
const containerResizeObserverRef = useRef<ResizeObserver | null>(null)
const textareaResizeObserverRef = useRef<ResizeObserver | null>(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,
}
}
@@ -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
}
@@ -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<void>) | undefined {
const config = FOLDER_CONFIGS[folderId]
if (!config.ensureLoadedKey) return undefined
return mentionData[config.ensureLoadedKey as keyof MentionDataReturn] as
| (() => Promise<void>)
| undefined
}
/**
* Extract specific ChatContext types for type-safe narrowing
*/
@@ -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) => <ProviderIcon provider={providerName} className='size-3' />,
[]
)
const getProviderIcon = useCallback((providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
if (!baseProviderConfig) {
return <SquareArrowUpRight className='size-3' />
}
const Icon: StyleableIcon = baseProviderConfig.icon
return <Icon className='size-3 text-[var(--text-icon)]' style={getBareIconStyle(Icon)} />
}, [])
const getProviderName = useCallback((providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)
@@ -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<TagDropdownProps> = ({
<>
<PopoverSection rootOnly>
<div className='flex items-center gap-1.5'>
<BlockTile bgColor={VARIABLE_TILE_COLOR} fallbackLabel='V' size='sm' />
<BlockTile bgColor={BLOCK_COLORS.VARIABLE} fallbackLabel='V' size='sm' />
Variables
</div>
</PopoverSection>
@@ -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) => (
<ProviderIcon provider={providerName} className='size-3' />
)
const getProviderIcon = (providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
if (!baseProviderConfig) {
return <SquareArrowUpRight className='size-3' />
}
return createElement(baseProviderConfig.icon, { className: 'size-3' })
}
const getProviderName = (providerName: OAuthProvider) => {
const serviceConfig = getServiceConfigByProviderId(providerName)
@@ -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<HTMLElement>('[data-toolbar-item-icon]')
onDragStart(e, item.type, isTriggerCapable, {
name: item.name,
bgColor: item.bgColor ?? DEFAULT_BLOCK_TILE_COLOR,
bgColor: item.bgColor ?? '#666666',
iconContainer,
})
},
@@ -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'
}
/**
@@ -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))
}
>
<BlockTile bgColor={VARIABLE_TILE_COLOR} fallbackLabel='V' size='sm' />
<div className='relative flex size-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm bg-[#8B5CF6]'>
<span className='text-[9px] text-white'>V</span>
</div>
<span
className={cn(
'truncate',
@@ -487,7 +488,9 @@ function ConnectionsSection({
handleKeyboardActivation(event, () => setExpandedEnvVars(!expandedEnvVars))
}
>
<BlockTile bgColor={DEFAULT_BLOCK_TILE_COLOR} fallbackLabel='E' size='sm' />
<div className='relative flex size-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm bg-[#6B7280]'>
<span className='text-[9px] text-white'>E</span>
</div>
<span
className={cn(
'truncate',
-7
View File
@@ -6,13 +6,6 @@ import { getBlock } from '@/blocks/registry'
/** Tile fill for a block that has no config of its own to colour it. */
export const DEFAULT_BLOCK_TILE_COLOR = '#6B7280'
/**
* Tile fill for a workflow variable. Not a block, but it is listed beside them
* in the tag dropdown and the preview panel's reference sections and those
* two had drifted to different colours for the same "V" tile.
*/
export const VARIABLE_TILE_COLOR = '#2F8BFF'
/**
* Subflow tiles. Loop and Parallel are canvas blocks with no registry config,
* so every surface that lists them had to special-case the pair; they resolve
@@ -50,7 +50,6 @@ import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
import { getAllBlocks } from '@/blocks'
import { BlockTile } from '@/blocks/block-tile'
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
import type { BlockConfig } from '@/blocks/types'
import { WorkspaceSelect } from '@/ee/access-control/components/workspace-select'
@@ -740,7 +739,12 @@ function BlockToolRow({
checked={isBlockAllowed}
onCheckedChange={() => onToggleBlock()}
/>
<BlockTile blockType={block.type} icon={BlockIcon} bgColor={block.bgColor} />
<div
className='relative flex size-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm'
style={{ background: block.bgColor }}
>
{BlockIcon && <BlockIcon className='!size-[9px] text-white' />}
</div>
<button
type='button'
onClick={() => isBlockAllowed && isExpandable && setExpanded((prev) => !prev)}
@@ -1776,11 +1780,12 @@ export function GroupDetail({
checked={isIntegrationAllowed(block.type)}
onCheckedChange={() => toggleIntegration(block.type)}
/>
<BlockTile
blockType={block.type}
icon={BlockIcon}
bgColor={block.bgColor}
/>
<div
className='relative flex size-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm'
style={{ background: block.bgColor }}
>
{BlockIcon && <BlockIcon className='!size-[9px] text-white' />}
</div>
<span className='truncate text-sm'>{block.name}</span>
</label>
{block.description && (
-1
View File
@@ -66,7 +66,6 @@ export {
export { blockTypeToIconMap } from '@/lib/integrations/icon-mapping'
export {
type OAuthServiceMatch,
resolveIntegrationBlockTypeForOAuth,
resolveOAuthServiceForIntegration,
resolveOAuthServiceForSlug,
} from '@/lib/integrations/oauth-service'
@@ -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([])
})
})
@@ -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<string, string> = (() => {
const claims = new Map<string, Set<string>>()
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<string, string>()
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`.
@@ -165,14 +165,8 @@ function ChipModal({
ChipModal.displayName = 'ChipModal'
export interface ChipModalHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* 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<HTMLDivElement, ChipModalHeaderProps>(
<div ref={ref} className={cn('flex flex-col', className)} {...props}>
<div className='flex min-w-0 items-center justify-between gap-2 px-4 pt-3'>
<div className='flex min-w-0 items-center gap-2'>
{React.isValidElement(Icon) ? (
Icon
) : Icon ? (
<Icon className={chipContentIconClass} />
) : null}
{Icon ? <Icon className={chipContentIconClass} /> : null}
<span className={chipContentLabelClass}>{children}</span>
</div>
<Button