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

* fix(integrations): read every service mark from one registry

A service looked like itself on the canvas and like nothing in particular
everywhere it was connected. `OAUTH_PROVIDERS` registers 93 icons and no colour
at all, so the surfaces built on it — the connect dialog above all — drew a flat
grey mark for a block whose config already carries its brand icon and `bgColor`.

Bridges the two: `resolveIntegrationBlockTypeForOAuth` maps any OAuth id (a
service id, a provider id, an extra authorization server) to the catalog block
behind it, so a credential surface holding only an OAuth identity can still
reach the registry. The connect dialog now wears the block's tile, and
`ChipModalHeader` takes a rendered mark so a tile can carry its own chrome
instead of being tinted with the header's grey.

Folds in the copies that had grown around the gap: `IntegrationTile` resolved
its fill from the registry but took its icon from whatever the caller passed —
one tile, two sources — and now defaults to the registry, with an override kept
for the family service-account marks that genuinely are not the block's. The
letter fallback it grew alongside was reading the catalog's `bgColor` while the
tile beside it read the registry's; both are the tile now. Two `getProviderIcon`
implementations for the same job (one tinted, one not) become one `ProviderIcon`,
the connector tile duplicated verbatim across two knowledge-base surfaces becomes
one `ConnectorTile`, and the permission rows that hardcoded `text-white` — which
renders white-on-white on a pale brand tile — go through `BlockTile`.

Public pages keep their generated catalog: importing the registry there would
ship 282 block configs to a marketing page, and `integrations.json` is generated
from the same `bgColor`, so the two cannot drift.

* fix(blocks): drop the dead copies of a block's colour

Sweeping the surfaces above turned up colour data nothing reads and colour data
two surfaces disagreed on.

Dead: `BLOCK_COLORS.DEFAULT/LOOP/PARALLEL` in the tag dropdown (only `VARIABLE`
was ever referenced), `BlockIconInfo.color` on table columns — whose consumer
documents that it deliberately ignores the colour, so the `#2F55FF` behind it
could never render — and the `bgColor` threaded into the add-resource dropdown,
whose row renders a bare tinted icon.

Disagreeing: the Variables tile is `#2F8BFF` in the tag dropdown and `#8B5CF6`
in the preview panel, for the same "V" on the same concept. Both now read
`VARIABLE_TILE_COLOR`, and the preview panel's two hand-rolled squares become
`BlockTile` like every other tile.

Four spellings of the neutral fallback (`#6B7280`, `#6b7280`, `#666666`, and a
`cancelled` status that happened to equal it) now point at
`DEFAULT_BLOCK_TILE_COLOR`.

The terminal and logs resolvers stay. They look like duplicates of `accent.ts`
but carry behaviour it does not have — status fills for synthesized
error/validation/cancelled rows, near-black contrast correction, MCP tool-id
parsing, and a model-provider branch — so folding them in is a behavioural
change, not a deletion.

* chore(copilot): delete the mention machinery nothing calls

The workflow panel's copilot tab renders `MothershipChat`, and that component
brings its own input — so `panel/components/copilot` no longer holds a
component at all, only the hook library the old input used. Five of those hooks
have no caller anywhere: `useMentionData`, `useMentionKeyboard`,
`useCaretViewport`, `useMentionInsertHandlers`, `useTextareaAutoResize`.

They are not all of it. `home/components/user-input` still imports
`useFileAttachments`, `useMentionMenu`, `useMentionTokens`,
`useContextManagement`, and `useIntegrationAutoMention` from this directory,
so it survives as a shared hook library rather than dead weight — which is why
this removes the uncalled five rather than the folder.

What they alone reached goes with them: `getFolderData` / `getFolderLoading` /
`getFolderEnsureLoaded` and the `FOLDER_CONFIGS` table describing every mention
folder, `buildMentionHighlightNodes`, the `MentionFolderNav` type, and the
slash-command tables. Of the 266-line constants file only `SCROLL_TOLERANCE`
had a live reader left.

* fix(integrations): never answer with a sibling's tile

Two integrations can share one OAuth id: Google Slides is authenticated by
Drive's `google-drive` service and Jira Service Management by Jira's `jira`.
Indexing first-write-wins made those ids resolve to whichever sorted first, so
the dialog connecting Slides could wear Drive's brand.

An id claimed by more than one block type now resolves to neither, and the
caller keeps the service-specific mark it already had. A wrong brand is worse
than no tile.
This commit is contained in:
Waleed
2026-08-14 14:40:20 -07:00
committed by GitHub
parent 5a88ce22d1
commit b7a5f22abd
37 changed files with 388 additions and 1692 deletions
@@ -19,6 +19,7 @@ import { useSession } from '@/lib/auth/auth-client'
import type { OAuthReturnContext } from '@/lib/credentials/client-state'
import { ADD_CONNECTOR_SEARCH_PARAM, writeOAuthReturnContext } from '@/lib/credentials/client-state'
import { defaultCredentialDisplayName } from '@/lib/credentials/display-name'
import { resolveIntegrationBlockTypeForOAuth } from '@/lib/integrations'
import {
getProviderIdFromServiceId,
OAUTH_PROVIDERS,
@@ -26,6 +27,7 @@ import {
parseProvider,
} from '@/lib/oauth'
import { getScopeDescription, getServiceConfigByProviderId } from '@/lib/oauth/utils'
import { BlockTile } from '@/blocks/block-tile'
import { useCreateCredentialDraft, useWorkspaceCredentials } from '@/hooks/queries/credentials'
import { useConnectOAuthService } from '@/hooks/queries/oauth/oauth-connections'
@@ -173,6 +175,20 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
return resolveService(provider, props.serviceId ?? providerId)
}, [props.serviceName, props.serviceIcon, props.provider, props.serviceId, providerId])
/**
* The block behind this OAuth identity, so the dialog wears the same brand
* tile the canvas and the integrations catalog do. Falls back to the bare
* `OAUTH_PROVIDERS` mark for an id no catalog integration claims.
*/
const headerIcon = useMemo(() => {
const blockType = resolveIntegrationBlockTypeForOAuth(
props.serviceId,
props.provider,
providerId
)
return blockType ? <BlockTile blockType={blockType} size='md' /> : ProviderIcon
}, [props.serviceId, props.provider, providerId, ProviderIcon])
const workspaceId = isConnect ? props.workspaceId : ''
const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({
workspaceId,
@@ -343,7 +359,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
return (
<ChipModal open={open} onOpenChange={onOpenChange} srTitle={title}>
<ChipModalHeader icon={ProviderIcon} onClose={handleClose}>
<ChipModalHeader icon={headerIcon} onClose={handleClose}>
{title}
</ChipModalHeader>
<ChipModalBody>
@@ -0,0 +1 @@
export { ProviderIcon } from './provider-icon'
@@ -0,0 +1,35 @@
'use client'
import { cn } from '@sim/emcn'
import { SquareArrowUpRight } from '@sim/emcn/icons'
import { OAUTH_PROVIDERS, type OAuthProvider, parseProvider } from '@/lib/oauth'
import { getBareIconStyle, type StyleableIcon } from '@/blocks/brand-icon-style'
interface ProviderIconProps {
provider: OAuthProvider
className?: string
}
/**
* The mark for an OAuth provider, tinted with the brand colour its block
* config registers. Credential rows show a bare icon rather than the filled
* tile the canvas uses, so the colour has to come through `iconColor` but it
* still comes from the same registry, which is what keeps a provider looking
* like itself everywhere it is listed.
*
* `OAUTH_PROVIDERS` carries the icon and no colour at all, so rendering
* straight from it is what left credential surfaces grey while the same
* service was branded a panel away. Falls back to a generic mark for a
* provider that map does not know.
*/
export function ProviderIcon({ provider, className }: ProviderIconProps) {
const { baseProvider } = parseProvider(provider)
const config = OAUTH_PROVIDERS[baseProvider]
if (!config) return <SquareArrowUpRight className={className} />
const Icon = config.icon as StyleableIcon
return (
<Icon className={cn('text-[var(--text-icon)]', className)} style={getBareIconStyle(Icon)} />
)
}
@@ -258,7 +258,6 @@ export function useAvailableResources(
id: integration.blockType,
name: integration.name,
iconComponent: integration.icon,
bgColor: integration.bgColor,
})),
},
{
@@ -7,7 +7,6 @@ import { randomFloat } from '@sim/utils/random'
import { stripVersionSuffix } from '@sim/utils/string'
import { useParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { GmailIcon, SlackIcon } from '@/components/icons'
import {
INTEGRATIONS,
type OAuthServiceMatch,
@@ -16,6 +15,7 @@ import {
} from '@/lib/integrations'
import { captureEvent } from '@/lib/posthog/client'
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
import { getBlockTileIcon } from '@/blocks/accent'
import { getBareIconStyle } from '@/blocks/brand-icon-style'
import { getAllBlockMeta } from '@/blocks/registry'
import type { ModuleTag } from '@/blocks/types'
@@ -224,6 +224,16 @@ function computeActions(services: readonly ServiceInfo[], signals: Signals): Act
return [...integrations, ...prompts]
}
/**
* Integrations pinned to the first paint. Named by block type so the mark comes
* from the same registry every other surface reads, rather than a second copy
* imported here that could drift from the block's own icon.
*/
const INITIAL_INTEGRATIONS = [
{ blockType: 'slack', slug: 'slack', name: 'Slack' },
{ blockType: 'gmail', slug: 'gmail', name: 'Gmail' },
] as const
/**
* Initial actions rendered on first paint, before OAuth/credentials queries
* resolve. For users with no connections this is also the final result, so the
@@ -231,20 +241,20 @@ function computeActions(services: readonly ServiceInfo[], signals: Signals): Act
* before the personalized recompute replaces it.
*/
const INITIAL_ACTIONS: Action[] = [
{
kind: 'integration',
id: 'integrate-slack',
label: 'Integrate with Slack',
icon: SlackIcon,
slug: 'slack',
},
{
kind: 'integration',
id: 'integrate-gmail',
label: 'Integrate with Gmail',
icon: GmailIcon,
slug: 'gmail',
},
...INITIAL_INTEGRATIONS.flatMap<Action>(({ blockType, slug, name }) => {
const icon = getBlockTileIcon(blockType)
return icon
? [
{
kind: 'integration',
id: `integrate-${slug}`,
label: `Integrate with ${name}`,
icon,
slug,
},
]
: []
}),
toPromptAction(TABLE_STARTERS[0]),
...CANDIDATES.filter((c) => c.blockType === 'github' && c.featured)
.slice(0, 1)
@@ -8,14 +8,12 @@ import { useQueryState } from 'nuqs'
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
import { isChatEnabled } from '@/lib/core/config/env-flags'
import {
blockTypeToIconMap,
type Integration,
resolveCredentialDisplay,
resolveOAuthServiceForIntegration,
} from '@/lib/integrations'
import { credentialProviderMatchesService } from '@/lib/oauth'
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
import { RESOURCE_TILE_BASE } from '@/app/workspace/[workspaceId]/components/resource-tile'
import { IntegrationSkillsSection } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section'
import { connectParam } from '@/app/workspace/[workspaceId]/integrations/[block]/search-params'
import {
@@ -34,7 +32,7 @@ import {
SettingsResourceRow,
} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import { getTileIconColorClass } from '@/blocks/icon-color'
import { getBlockTileIcon } from '@/blocks/accent'
import { storeCuratedPrompt } from '@/blocks/integration-matcher'
import {
getSuggestedSkillsForBlock,
@@ -64,7 +62,6 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
useOAuthReturnRouter()
const router = useRouter()
const [connectMode, setConnectMode] = useQueryState(connectParam.key, connectParam.parser)
const Icon = blockTypeToIconMap[integration.type]
const matchingTemplates = getTemplatesForBlock(integration.type)
const suggestedSkills = getSuggestedSkillsForBlock(integration.type)
const oauthService = resolveOAuthServiceForIntegration(integration)
@@ -233,16 +230,10 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
>
<div className='mx-auto flex max-w-[48rem] flex-col gap-7 pb-3'>
<div className='flex flex-col gap-3'>
{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>
)}
<IntegrationTile
blockType={integration.type}
fallbackLabel={integration.name.charAt(0)}
/>
<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>
@@ -255,7 +246,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
<SettingsResourceRow
key={credential.id}
iconVariant='custom'
icon={Icon && <IntegrationTile blockType={integration.type} icon={Icon} />}
icon={<IntegrationTile blockType={integration.type} />}
title={credential.displayName}
description={
credential.description || resolveCredentialDisplay(credential).subtitle
@@ -374,8 +365,7 @@ function TemplateIcons({ blockTypes }: TemplateIconsProps) {
return (
<span aria-hidden className='flex items-center'>
{blockTypes.map((bt, idx) => {
const ToolIcon = blockTypeToIconMap[bt]
if (!ToolIcon) return null
if (!getBlockTileIcon(bt)) return null
const z = TEMPLATE_TILE_Z[idx]
if (!z) return null
const isTrailing = idx > 0
@@ -389,7 +379,7 @@ function TemplateIcons({ blockTypes }: TemplateIconsProps) {
'outline outline-2 outline-[var(--bg)] transition-[outline-color] duration-150 group-hover:outline-[var(--surface-active)]'
)}
>
<IntegrationTile blockType={bt} icon={ToolIcon} />
<IntegrationTile blockType={bt} />
</span>
)
})}
@@ -5,6 +5,7 @@ import {
RESOURCE_TILE_PLAIN,
} from '@/app/workspace/[workspaceId]/components/resource-tile'
import { getBlock } from '@/blocks'
import { getBlockTileIcon } from '@/blocks/accent'
import { getTileIconColorClass } from '@/blocks/icon-color'
/**
@@ -59,7 +60,15 @@ function resolveBrandTileBg(blockType: string): string | null {
interface IntegrationTileProps {
blockType: string
icon: ComponentType<{ className?: string }>
/**
* Overrides the block's registered mark. Only for a tile whose identity is
* not the block itself a credential issued by a family service account
* wears the family's corporate mark. Everything else takes the registry's,
* so the tile cannot end up with its fill and its icon from two sources.
*/
icon?: ComponentType<{ className?: string }>
/** Drawn when neither the override nor the registry supplies a mark. */
fallbackLabel?: string
framed?: boolean
}
@@ -68,16 +77,23 @@ interface IntegrationTileProps {
* is a 36px tile used in list rows and headers; the framed variant adds an
* outer 44px halo used inside the showcase grid.
*/
export function IntegrationTile({ blockType, icon: Icon, framed = false }: IntegrationTileProps) {
export function IntegrationTile({
blockType,
icon,
fallbackLabel,
framed = false,
}: IntegrationTileProps) {
const brandBg = resolveBrandTileBg(blockType)
const Icon = icon ?? getBlockTileIcon(blockType)
const contentClass = getTileIconColorClass(brandBg)
if (!framed) {
return (
<div
className={cn(RESOURCE_TILE_BASE, RESOURCE_TILE_PLAIN)}
className={cn(RESOURCE_TILE_BASE, RESOURCE_TILE_PLAIN, !Icon && contentClass)}
style={brandBg ? { background: brandBg } : undefined}
>
<Icon className={getTileIconColorClass(brandBg)} />
{Icon ? <Icon className={contentClass} /> : fallbackLabel}
</div>
)
}
@@ -85,10 +101,13 @@ export function IntegrationTile({ blockType, icon: Icon, framed = false }: Integ
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='flex size-full items-center justify-center rounded-[9px] border border-[var(--border-1)] bg-[var(--bg)]'
className={cn(
'flex size-full items-center justify-center rounded-[9px] border border-[var(--border-1)] bg-[var(--bg)]',
!Icon && contentClass
)}
style={brandBg ? { background: brandBg } : undefined}
>
<Icon className={cn('size-6', getTileIconColorClass(brandBg))} />
{Icon ? <Icon className={cn('size-6', contentClass)} /> : fallbackLabel}
</div>
</div>
)
@@ -8,7 +8,6 @@ import {
ChipInput,
ChipLink,
ChipTextarea,
cn,
Send,
toast,
} from '@sim/emcn'
@@ -28,10 +27,6 @@ import {
UnsavedChangesModal,
useCredentialDetailForm,
} from '@/app/workspace/[workspaceId]/components/credential-detail'
import {
RESOURCE_TILE_BASE,
RESOURCE_TILE_PLAIN,
} from '@/app/workspace/[workspaceId]/components/resource-tile'
import {
ConnectServiceAccountModal,
type ServiceAccountProviderId,
@@ -244,15 +239,11 @@ export function ConnectedCredentialDetail({
<CredentialDetailLayout back={back} actions={actions}>
<CredentialDetailHeading
leading={
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>
)
<IntegrationTile
blockType={integrationBlockType}
icon={display?.icon ?? undefined}
fallbackLabel={resolveProviderLabel(credential.providerId).slice(0, 1) || '?'}
/>
}
title={headingTitle}
subtitle={display?.detailSubtitle ?? 'Connected service'}
@@ -14,7 +14,6 @@ import {
import { useParams } from 'next/navigation'
import { useQueryStates } from 'nuqs'
import {
blockTypeToIconMap,
formatIntegrationType,
INTEGRATIONS,
type Integration,
@@ -34,6 +33,7 @@ import {
} from '@/app/workspace/[workspaceId]/integrations/search-params'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
import { getBlockTileIcon } from '@/blocks/accent'
import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials'
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
import { usePermissionConfig } from '@/hooks/use-permission-config'
@@ -68,7 +68,6 @@ interface IntegrationItemProps {
workspaceId: string
name: string
description?: string | null
icon: ComponentType<{ className?: string }>
unavailable?: boolean
}
@@ -78,13 +77,12 @@ function IntegrationItem({
workspaceId,
name,
description,
icon: Icon,
unavailable = false,
}: IntegrationItemProps) {
return (
<SettingsResourceRow
iconVariant='custom'
icon={<IntegrationTile blockType={blockType} icon={Icon} />}
icon={<IntegrationTile blockType={blockType} />}
title={name}
description={
unavailable
@@ -348,8 +346,7 @@ export function Integrations() {
{filteredCategorySections.map((section) => (
<IntegrationSection key={section.label} label={formatIntegrationType(section.label)}>
{section.integrations.map((integration) => {
const Icon = blockTypeToIconMap[integration.type]
if (!Icon) return null
if (!getBlockTileIcon(integration.type)) return null
const availability = integrationAvailability.get(integration.type.toLowerCase())
const deploymentUnavailable =
availability?.state === 'unavailable' || availability?.state === 'misconfigured'
@@ -361,7 +358,6 @@ export function Integrations() {
workspaceId={workspaceId}
name={integration.name}
description={integration.description}
icon={Icon}
unavailable={integration.authType === 'oauth' && deploymentUnavailable}
/>
)
@@ -16,7 +16,6 @@ import {
ChipModalFooter,
ChipModalHeader,
type ComboboxOption,
cn,
handleKeyboardActivation,
Search,
} from '@sim/emcn'
@@ -31,12 +30,11 @@ import {
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
import { ConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields'
import { hasWorkspaceMaxConnectorAccess } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements'
import { ConnectorTile } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-tile'
import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts'
import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge'
import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import { getBlock } from '@/blocks'
import { getTileIconColorClass } from '@/blocks/icon-color'
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
import type { ConnectorMeta } from '@/connectors/types'
import { useCreateConnector } from '@/hooks/queries/kb/connectors'
@@ -479,9 +477,6 @@ interface ConnectorTypeCardProps {
}
function ConnectorTypeCard({ type, config, onClick }: ConnectorTypeCardProps) {
const Icon = config.icon
const brandBg = getBlock(type)?.bgColor ?? null
return (
<button
type='button'
@@ -489,22 +484,7 @@ function ConnectorTypeCard({ type, config, onClick }: ConnectorTypeCardProps) {
onClick={onClick}
>
<div className='size-9 flex-shrink-0'>
<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>
<ConnectorTile connectorType={type} icon={config.icon} />
</div>
<div className='flex min-w-0 flex-1 flex-col'>
<span className='truncate text-[var(--text-body)] text-sm'>{config.name}</span>
@@ -0,0 +1,44 @@
'use client'
import type { ComponentType } from 'react'
import { cn } from '@sim/emcn'
import { getBlock } from '@/blocks'
import { getTileIconColorClass } from '@/blocks/icon-color'
interface ConnectorTileProps {
/** Connector type, which doubles as the block type owning the brand colour. */
connectorType: string
icon?: ComponentType<{ className?: string }>
}
/**
* 36px brand tile for a knowledge-base connector. The fill comes from the block
* registry so a connector reads the same as the integration it syncs from;
* connectors carry an icon of their own but no colour, which is why the two are
* resolved from different places here.
*
* A connector whose type has no block config keeps the neutral surface rather
* than inventing a colour.
*/
export function ConnectorTile({ connectorType, icon: Icon }: ConnectorTileProps) {
const brandBg = getBlock(connectorType)?.bgColor ?? null
return (
<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>
)
}
@@ -0,0 +1 @@
export { ConnectorTile } from './connector-tile'
@@ -32,9 +32,8 @@ import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/creden
import { getCanonicalScopesForProvider, getProviderIdFromServiceId } from '@/lib/oauth'
import { getMissingRequiredScopes } from '@/lib/oauth/utils'
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
import { ConnectorTile } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-tile'
import { EditConnectorModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal'
import { getBlock } from '@/blocks'
import { getTileIconColorClass } from '@/blocks/icon-color'
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
import type { ConnectorData, SyncLogData } from '@/hooks/queries/kb/connectors'
import {
@@ -309,7 +308,6 @@ function ConnectorCard({
const connectorDef = CONNECTOR_META_REGISTRY[connector.connectorType]
const Icon = connectorDef?.icon
const brandBg = getBlock(connector.connectorType)?.bgColor ?? null
const statusConfig =
STATUS_CONFIG[connector.status as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.active
@@ -359,24 +357,7 @@ function ConnectorCard({
<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'>
<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>
<ConnectorTile connectorType={connector.connectorType} icon={Icon} />
{connector.status === 'disabled' && (
<TriangleAlert className='-right-0.5 -top-0.5 absolute size-3 text-[var(--caution)]' />
)}
@@ -7,6 +7,7 @@ import type { TraceSpan } from '@/lib/logs/types'
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
import { getBlock, getBlockByToolName } from '@/blocks'
import { DEFAULT_BLOCK_TILE_COLOR } from '@/blocks/accent'
import { PROVIDER_DEFINITIONS } from '@/providers/models'
import { normalizeToolId } from '@/tools/normalize'
@@ -24,8 +25,6 @@ function tryParseMcpToolName(toolId: string): string | null {
return toolName.length > 0 ? toolName : null
}
export const DEFAULT_BLOCK_COLOR = '#6b7280'
export interface BlockIconAndColor {
icon: React.ComponentType<{ className?: string }> | null
bgColor: string
@@ -71,12 +70,12 @@ export function getBlockIconAndColor(
if (lowerType === 'model' && provider) {
const providerDef = PROVIDER_DEFINITIONS[provider]
if (providerDef?.icon)
return { icon: providerDef.icon, bgColor: providerDef.color ?? DEFAULT_BLOCK_COLOR }
return { icon: providerDef.icon, bgColor: providerDef.color ?? DEFAULT_BLOCK_TILE_COLOR }
}
const blockType = lowerType === 'model' ? 'agent' : lowerType
const blockConfig = getBlock(blockType)
if (blockConfig) return { icon: blockConfig.icon, bgColor: blockConfig.bgColor }
return { icon: null, bgColor: DEFAULT_BLOCK_COLOR }
return { icon: null, bgColor: DEFAULT_BLOCK_TILE_COLOR }
}
/**
@@ -93,7 +92,9 @@ const MAX_YIQ_SUM = 255_000
*/
export function adjustBgForContrast(bgColor: string): string {
const brightness = perceivedBrightness(bgColor)
return brightness !== null && brightness < 30_000 / MAX_YIQ_SUM ? DEFAULT_BLOCK_COLOR : bgColor
return brightness !== null && brightness < 30_000 / MAX_YIQ_SUM
? DEFAULT_BLOCK_TILE_COLOR
: bgColor
}
export function parseTime(value?: string | number | null): number {
@@ -1,9 +1,13 @@
import type React from 'react'
import type { ColumnDefinition } from '@/lib/table'
/**
* The producing block's mark for a workflow-output column. Icon only these
* render in the plain `--text-icon` tone like every other column-type icon, so
* carrying a colour here only invited a second copy of the block's `bgColor`.
*/
export interface BlockIconInfo {
icon: React.ComponentType<{ className?: string }>
color: string
}
export interface ColumnSourceInfo {
@@ -244,7 +244,7 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams)
const block = blocks?.[out.blockId]
const blockConfig = block?.type ? getBlock(block.type) : undefined
const blockIconInfo: BlockIconInfo | undefined = blockConfig?.icon
? { icon: blockConfig.icon, color: blockConfig.bgColor || '#2F55FF' }
? { icon: blockConfig.icon }
: undefined
const blockName = block?.name?.trim() || undefined
// Flag a missing source block only once the workflow state has loaded
@@ -1,266 +1,4 @@
import type { ChatContext } from '@/stores/panel'
/**
* Mention folder types
*/
export type MentionFolderId =
| 'chats'
| 'workflows'
| 'knowledge'
| 'blocks'
| 'workflow-blocks'
| 'logs'
| 'integrations'
/**
* Menu item category types for mention menu (includes folders + docs item)
*/
export type MentionCategory = MentionFolderId | 'docs'
/**
* Configuration interface for folder types
*/
export interface FolderConfig<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,10 +1,5 @@
export { useCaretViewport } from './use-caret-viewport'
export { useContextManagement } from './use-context-management'
export { useFileAttachments } from './use-file-attachments'
export { useIntegrationAutoMention } from './use-integration-auto-mention'
export { useMentionData } from './use-mention-data'
export { useMentionInsertHandlers } from './use-mention-insert-handlers'
export { useMentionKeyboard } from './use-mention-keyboard'
export { useMentionMenu } from './use-mention-menu'
export { useMentionTokens } from './use-mention-tokens'
export { useTextareaAutoResize } from './use-textarea-auto-resize'
@@ -1,77 +0,0 @@
import { useMemo } from 'react'
interface CaretViewportPosition {
left: number
top: number
}
interface UseCaretViewportResult {
caretViewport: CaretViewportPosition | null
side: 'top' | 'bottom'
}
interface UseCaretViewportProps {
textareaRef: React.RefObject<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])
}
@@ -1,365 +0,0 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useShallow } from 'zustand/react/shallow'
import { requestJson } from '@/lib/api/client/request'
import { listCopilotChatsContract } from '@/lib/api/contracts/copilot'
import { listKnowledgeBasesContract } from '@/lib/api/contracts/knowledge/base'
import { listLogsContract } from '@/lib/api/contracts/logs'
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
import { type IntegrationDescriptor, listIntegrations } from '@/blocks/integration-matcher'
import { useWorkflows } from '@/hooks/queries/workflows'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('useMentionData')
/**
* Represents a past chat for mention suggestions
*/
export interface PastChat {
id: string
title: string | null
workflowId: string | null
updatedAt?: string
}
/**
* Represents a workflow for mention suggestions
*/
export interface WorkflowItem {
id: string
name: string
color?: string
}
/**
* Represents a knowledge base for mention suggestions
*/
export interface KnowledgeItem {
id: string
name: string
}
/**
* Represents a block for mention suggestions
*/
export interface BlockItem {
id: string
name: string
iconComponent?: any
bgColor?: string
}
/**
* Represents a workflow block for mention suggestions
*/
export interface WorkflowBlockItem {
id: string
name: string
type: string
iconComponent?: any
bgColor?: string
}
/**
* Represents a log/execution for mention suggestions
*/
export interface LogItem {
id: string
executionId?: string
level: string
trigger: string | null
createdAt: string
workflowName: string
}
interface UseMentionDataProps {
workflowId: string | null
workspaceId: string
}
/**
* Return type for useMentionData hook
*/
export interface MentionDataReturn {
// Data arrays
pastChats: PastChat[]
workflows: WorkflowItem[]
knowledgeBases: KnowledgeItem[]
blocksList: BlockItem[]
workflowBlocks: WorkflowBlockItem[]
logsList: LogItem[]
integrations: readonly IntegrationDescriptor[]
// Loading states
isLoadingPastChats: boolean
isLoadingWorkflows: boolean
isLoadingKnowledge: boolean
isLoadingBlocks: boolean
isLoadingWorkflowBlocks: boolean
isLoadingLogs: boolean
isLoadingIntegrations: boolean
// Ensure loaded functions
ensurePastChatsLoaded: () => Promise<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,
}
}
@@ -1,137 +0,0 @@
import { useCallback, useMemo } from 'react'
import {
DOCS_CONFIG,
FOLDER_CONFIGS,
type FolderConfig,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import type { useMentionMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu'
import type { MentionFolderNav } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/types'
import { isContextAlreadySelected } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
import type { ChatContext } from '@/stores/panel'
interface UseMentionInsertHandlersProps {
/** Mention menu hook instance */
mentionMenu: ReturnType<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
}
@@ -1,355 +0,0 @@
import { type KeyboardEvent, useCallback, useMemo } from 'react'
import {
FOLDER_CONFIGS,
FOLDER_ORDER,
type MentionFolderId,
ROOT_MENU_ITEM_COUNT,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import type {
useMentionData,
useMentionMenu,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
import type { MentionFolderNav } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/types'
import {
getFolderData as getFolderDataUtil,
getFolderEnsureLoaded as getFolderEnsureLoadedUtil,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
interface UseMentionKeyboardProps {
/** Mention menu hook instance */
mentionMenu: ReturnType<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,
}
}
@@ -1,232 +0,0 @@
'use client'
import { type RefObject, useEffect, useLayoutEffect, useRef } from 'react'
/**
* Maximum textarea height in pixels
*/
const MAX_TEXTAREA_HEIGHT = 120
interface UseTextareaAutoResizeProps {
/** Current message content */
message: string
/** Width of the panel */
panelWidth: number
/** Selected mention contexts */
selectedContexts: any[]
/** External textarea ref to sync with */
textareaRef: RefObject<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,
}
}
@@ -1,11 +0,0 @@
import type { MentionFolderId } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
/**
* Shared folder navigation state for the mention menu.
*/
export interface MentionFolderNav {
currentFolder: MentionFolderId | null
isInFolder: boolean
openFolder: (folderId: MentionFolderId, title: string) => void
closeFolder: () => void
}
@@ -1,9 +1,3 @@
import type { ReactNode } from 'react'
import {
FOLDER_CONFIGS,
type MentionFolderId,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import type { MentionDataReturn } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data'
import type { ChatContext } from '@/stores/panel'
/**
@@ -123,79 +117,6 @@ export function computeMentionHighlightRanges(
return ranges
}
/**
* Builds React nodes with highlighted mention tokens
* @param text - Text to render
* @param contexts - Chat contexts to highlight
* @param createHighlightSpan - Function to create highlighted span element
* @returns Array of React nodes with highlighted mentions
*/
export function buildMentionHighlightNodes(
text: string,
contexts: ChatContext[],
createHighlightSpan: (token: string, key: string) => ReactNode
): ReactNode[] {
const tokens = extractContextTokens(contexts)
if (!tokens.length) return [text]
const ranges = computeMentionHighlightRanges(text, tokens)
if (!ranges.length) return [text]
const nodes: ReactNode[] = []
let lastIndex = 0
for (const range of ranges) {
if (range.start > lastIndex) {
nodes.push(text.slice(lastIndex, range.start))
}
nodes.push(createHighlightSpan(range.token, `mention-${range.start}-${range.end}`))
lastIndex = range.end
}
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex))
}
return nodes
}
/**
* Gets the data array for a folder ID from mentionData.
* Uses FOLDER_CONFIGS as the source of truth for key mapping.
* Returns any[] since item types vary by folder and are used with dynamic config.filterFn
*/
export function getFolderData(mentionData: MentionDataReturn, folderId: MentionFolderId): any[] {
const config = FOLDER_CONFIGS[folderId]
return (mentionData[config.dataKey as keyof MentionDataReturn] as any[]) || []
}
/**
* Gets the loading state for a folder ID from mentionData.
* Uses FOLDER_CONFIGS as the source of truth for key mapping.
*/
export function getFolderLoading(
mentionData: MentionDataReturn,
folderId: MentionFolderId
): boolean {
const config = FOLDER_CONFIGS[folderId]
return mentionData[config.loadingKey as keyof MentionDataReturn] as boolean
}
/**
* Gets the ensure loaded function for a folder ID from mentionData.
* Uses FOLDER_CONFIGS as the source of truth for key mapping.
*/
export function getFolderEnsureLoaded(
mentionData: MentionDataReturn,
folderId: MentionFolderId
): (() => Promise<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,6 +14,7 @@ import {
} from '@/lib/oauth'
import { getMissingRequiredScopes, getServiceConfigByServiceId } from '@/lib/oauth/utils'
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
import { ProviderIcon } from '@/app/workspace/[workspaceId]/components/provider-icon'
import {
ConnectServiceAccountModal,
type ServiceAccountProviderId,
@@ -24,7 +25,6 @@ import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider'
import { getBareIconStyle, type StyleableIcon } from '@/blocks/brand-icon-style'
import type { SubBlockConfig } from '@/blocks/types'
import { useWorkspaceCredential, useWorkspaceCredentials } from '@/hooks/queries/credentials'
import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
@@ -226,16 +226,10 @@ export function CredentialSelector({
setShowConnectModal(true)
}, [credentialKind])
const getProviderIcon = useCallback((providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
if (!baseProviderConfig) {
return <SquareArrowUpRight className='size-3' />
}
const Icon: StyleableIcon = baseProviderConfig.icon
return <Icon className='size-3 text-[var(--text-icon)]' style={getBareIconStyle(Icon)} />
}, [])
const getProviderIcon = useCallback(
(providerName: OAuthProvider) => <ProviderIcon provider={providerName} className='size-3' />,
[]
)
const getProviderName = useCallback((providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)
@@ -29,6 +29,7 @@ import type {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/types'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { getBlock } from '@/blocks'
import { VARIABLE_TILE_COLOR } from '@/blocks/accent'
import { BlockTile } from '@/blocks/block-tile'
import type { BlockConfig } from '@/blocks/types'
import { normalizeName } from '@/executor/constants'
@@ -154,16 +155,6 @@ export const getTagSearchTerm = (text: string, cursorPosition: number): string =
return textBeforeCursor.slice(lastOpenBracket + 1).toLowerCase()
}
/**
* Color constants for block type icons in the tag dropdown.
*/
const BLOCK_COLORS = {
VARIABLE: '#2F8BFF',
DEFAULT: '#2F55FF',
LOOP: '#2FB3FF',
PARALLEL: '#FEE12B',
} as const
/**
* Prefix constants for special tag types.
*/
@@ -1709,7 +1700,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
<>
<PopoverSection rootOnly>
<div className='flex items-center gap-1.5'>
<BlockTile bgColor={BLOCK_COLORS.VARIABLE} fallbackLabel='V' size='sm' />
<BlockTile bgColor={VARIABLE_TILE_COLOR} fallbackLabel='V' size='sm' />
Variables
</div>
</PopoverSection>
@@ -1,8 +1,7 @@
'use client'
import { createElement, useCallback, useMemo, useRef, useState } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { Button, Combobox } from '@sim/emcn'
import { SquareArrowUpRight } from '@sim/emcn/icons'
import { useParams } from 'next/navigation'
import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state'
import {
@@ -16,6 +15,7 @@ import {
} from '@/lib/oauth'
import { getMissingRequiredScopes } from '@/lib/oauth/utils'
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
import { ProviderIcon } from '@/app/workspace/[workspaceId]/components/provider-icon'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider'
@@ -25,15 +25,9 @@ import { useWorkflowMap } from '@/hooks/queries/workflows'
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const getProviderIcon = (providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
if (!baseProviderConfig) {
return <SquareArrowUpRight className='size-3' />
}
return createElement(baseProviderConfig.icon, { className: 'size-3' })
}
const getProviderIcon = (providerName: OAuthProvider) => (
<ProviderIcon provider={providerName} className='size-3' />
)
const getProviderName = (providerName: OAuthProvider) => {
const serviceConfig = getServiceConfigByProviderId(providerName)
@@ -33,6 +33,7 @@ import {
import { useToolbarItemInteractions } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/hooks'
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
import { DEFAULT_BLOCK_TILE_COLOR } from '@/blocks/accent'
import { BlockTile } from '@/blocks/block-tile'
import {
buildCustomBlockConfig,
@@ -89,7 +90,7 @@ const ToolbarItem = memo(function ToolbarItem({
const iconContainer = e.currentTarget.querySelector<HTMLElement>('[data-toolbar-item-icon]')
onDragStart(e, item.type, isTriggerCapable, {
name: item.name,
bgColor: item.bgColor ?? '#666666',
bgColor: item.bgColor ?? DEFAULT_BLOCK_TILE_COLOR,
iconContainer,
})
},
@@ -1,6 +1,7 @@
import type React from 'react'
import { Ban, CircleX, Repeat, Split, TriangleAlert, Workflow } from '@sim/emcn/icons'
import { getBlock } from '@/blocks'
import { DEFAULT_BLOCK_TILE_COLOR } from '@/blocks/accent'
import { isWorkflowBlockType } from '@/executor/constants'
import { TERMINAL_BLOCK_COLUMN_WIDTH } from '@/stores/constants'
import type { ConsoleEntry } from '@/stores/terminal'
@@ -20,7 +21,7 @@ const SUBFLOW_COLORS = {
const SPECIAL_BLOCK_COLORS = {
error: '#ef4444',
validation: '#f59e0b',
cancelled: '#6b7280',
cancelled: DEFAULT_BLOCK_TILE_COLOR,
} as const
/**
@@ -90,7 +91,7 @@ export function getBlockColor(blockType: string): string {
if (blockType === 'cancelled') {
return SPECIAL_BLOCK_COLORS.cancelled
}
return '#6b7280'
return DEFAULT_BLOCK_TILE_COLOR
}
/**
@@ -43,6 +43,7 @@ import { PreviewContextMenu } from '@/app/workspace/[workspaceId]/w/components/p
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { getBlock } from '@/blocks'
import { DEFAULT_BLOCK_TILE_COLOR, VARIABLE_TILE_COLOR } from '@/blocks/accent'
import { BlockTile } from '@/blocks/block-tile'
import type { BlockConfig, SubBlockConfig, SubBlockType } from '@/blocks/types'
import { normalizeName } from '@/executor/constants'
@@ -436,9 +437,7 @@ function ConnectionsSection({
handleKeyboardActivation(event, () => setExpandedVariables(!expandedVariables))
}
>
<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>
<BlockTile bgColor={VARIABLE_TILE_COLOR} fallbackLabel='V' size='sm' />
<span
className={cn(
'truncate',
@@ -488,9 +487,7 @@ function ConnectionsSection({
handleKeyboardActivation(event, () => setExpandedEnvVars(!expandedEnvVars))
}
>
<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>
<BlockTile bgColor={DEFAULT_BLOCK_TILE_COLOR} fallbackLabel='E' size='sm' />
<span
className={cn(
'truncate',
+7
View File
@@ -6,6 +6,13 @@ 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,6 +50,7 @@ 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'
@@ -739,12 +740,7 @@ function BlockToolRow({
checked={isBlockAllowed}
onCheckedChange={() => onToggleBlock()}
/>
<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>
<BlockTile blockType={block.type} icon={BlockIcon} bgColor={block.bgColor} />
<button
type='button'
onClick={() => isBlockAllowed && isExpandable && setExpanded((prev) => !prev)}
@@ -1780,12 +1776,11 @@ export function GroupDetail({
checked={isIntegrationAllowed(block.type)}
onCheckedChange={() => toggleIntegration(block.type)}
/>
<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>
<BlockTile
blockType={block.type}
icon={BlockIcon}
bgColor={block.bgColor}
/>
<span className='truncate text-sm'>{block.name}</span>
</label>
{block.description && (
+1
View File
@@ -66,6 +66,7 @@ export {
export { blockTypeToIconMap } from '@/lib/integrations/icon-mapping'
export {
type OAuthServiceMatch,
resolveIntegrationBlockTypeForOAuth,
resolveOAuthServiceForIntegration,
resolveOAuthServiceForSlug,
} from '@/lib/integrations/oauth-service'
@@ -1,13 +1,16 @@
/**
* @vitest-environment node
*/
import { stripVersionSuffix } from '@sim/utils/string'
import { describe, expect, it } from 'vitest'
import integrationsJson from '@/lib/integrations/integrations.json'
import {
resolveIntegrationBlockTypeForOAuth,
resolveOAuthServiceForSlug,
resolveServiceAccountIntegration,
} from '@/lib/integrations/oauth-service'
import type { Integration } from '@/lib/integrations/types'
import { getBlockTileColor, getBlockTileIcon } from '@/blocks/accent'
const INTEGRATIONS = integrationsJson.integrations as readonly Integration[]
@@ -181,3 +184,94 @@ describe('resolveServiceAccountIntegration', () => {
expect(resolveServiceAccountIntegration(' ')).toBeNull()
})
})
/**
* Integrations whose `oauthServiceId` is shared with a sibling, so the id names
* a pair rather than a block: Google Slides rides Drive's service, Jira Service
* Management rides Jira's. The bridge deliberately resolves none of them.
*/
const SHARED_OAUTH_ID_SLUGS = ['google-drive', 'google-slides', 'jira', 'jira-service-management']
/** Resolved block type with any version suffix dropped, for stable assertions. */
function baseTypeFor(...keys: (string | undefined)[]): string | undefined {
const blockType = resolveIntegrationBlockTypeForOAuth(...keys)
return blockType ? stripVersionSuffix(blockType) : undefined
}
describe('resolveIntegrationBlockTypeForOAuth', () => {
it.concurrent('resolves a service id, a provider id, and an extra auth server', () => {
// Each is a distinct key shape a credential surface can be holding: the
// service id a block declares, the provider id that service registers, and
// the second authorization server Salesforce accepts. The catalog carries
// versioned types (`gmail_v2`), so compare on the base — the version that
// wins is whichever the catalog lists first and is not the contract here.
expect(baseTypeFor('gmail')).toBe('gmail')
expect(baseTypeFor('google-email')).toBe('gmail')
expect(baseTypeFor('salesforce-sandbox')).toBe('salesforce')
})
it.concurrent('is case-insensitive and skips empty keys', () => {
expect(baseTypeFor('GOOGLE-EMAIL')).toBe('gmail')
expect(resolveIntegrationBlockTypeForOAuth(undefined, '', 'slack')).toBeDefined()
})
it.concurrent('takes the first key that resolves, so callers can order by specificity', () => {
// A connect dialog passes the service id before the provider id it derives
// from; the specific one has to win or every Google service would render
// whichever member the catalog happens to list first.
expect(baseTypeFor('google-sheets', 'google-email')).toBe('google_sheets')
expect(baseTypeFor('unknown-service', 'google-email')).toBe('gmail')
})
it.concurrent('returns undefined for an id no catalog integration claims', () => {
// The signal to keep the caller's existing mark rather than invent one.
expect(resolveIntegrationBlockTypeForOAuth('not-a-real-service')).toBeUndefined()
expect(resolveIntegrationBlockTypeForOAuth()).toBeUndefined()
expect(resolveIntegrationBlockTypeForOAuth(undefined, '')).toBeUndefined()
})
it.concurrent('refuses to guess when one OAuth id names more than one block', () => {
// Google Slides is authenticated by Drive's service and JSM by Jira's, so
// these ids name a pair. Answering with either member would put the wrong
// brand on the other's connect dialog, so the bridge declines.
expect(resolveIntegrationBlockTypeForOAuth('google-drive')).toBeUndefined()
expect(resolveIntegrationBlockTypeForOAuth('jira')).toBeUndefined()
})
it.concurrent('resolves every OAuth integration whose id names it alone', () => {
// A credential surface that cannot reach a block type falls back to the
// colourless OAUTH_PROVIDERS mark, which is the bug this bridge exists to
// close — so every unambiguous integration must be in the index, and the
// only permitted misses are the shared ids above.
const unresolved = INTEGRATIONS.filter(
(integration) =>
integration.authType === 'oauth' &&
integration.oauthServiceId &&
!resolveIntegrationBlockTypeForOAuth(integration.oauthServiceId)
).map((integration) => integration.slug)
expect(unresolved.sort()).toEqual(SHARED_OAUTH_ID_SLUGS)
})
})
describe('the OAuth bridge reaches a renderable tile', () => {
it('resolves every OAuth integration to a block carrying both an icon and a fill', () => {
// The bridge exists so a credential surface can draw the block's tile.
// A type that resolves but has no registered icon or colour would paint an
// empty square in the connect dialog — worse than the grey mark it replaced.
const broken = INTEGRATIONS.filter(
(integration) =>
integration.authType === 'oauth' &&
integration.oauthServiceId &&
!SHARED_OAUTH_ID_SLUGS.includes(integration.slug)
).flatMap((integration) => {
const blockType = resolveIntegrationBlockTypeForOAuth(integration.oauthServiceId)
if (!blockType) return [`${integration.slug}: unresolved`]
if (!getBlockTileIcon(blockType)) return [`${integration.slug} -> ${blockType}: no icon`]
if (!getBlockTileColor(blockType)) return [`${integration.slug} -> ${blockType}: no fill`]
return []
})
expect(broken).toEqual([])
})
})
@@ -60,6 +60,72 @@ export function resolveOAuthServiceForSlug(slug: string): OAuthServiceMatch | nu
return resolveOAuthServiceForIntegration(integration)
}
/**
* Catalog block type for every OAuth key that can name it the service id its
* block declares, the provider id that service registers, and any additional
* authorization servers it accepts (Salesforce sandbox).
*
* Credential surfaces are handed an OAuth identity, not a block type, which is
* why they historically rendered a bare mark from `OAUTH_PROVIDERS` the only
* registry they could reach. That map carries an icon and no colour, so the
* same service showed its brand on the canvas and a flat grey in a connect
* dialog. This is the bridge back: given any OAuth id, the block whose config
* owns the icon and `bgColor`.
*
* A key two integrations both claim resolves to neither. Google Slides is
* authenticated by Drive's `google-drive` service and Jira Service Management
* by Jira's `jira`, so those ids name a pair, not a block and picking the
* one that happens to sort first would put Drive's tile on the dialog opening
* Slides. Dropping the key falls the caller back to the service-specific mark
* it already had, which is the honest answer: no wrong brand.
*/
const BLOCK_TYPE_BY_OAUTH_KEY: ReadonlyMap<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,8 +165,14 @@ function ChipModal({
ChipModal.displayName = 'ChipModal'
export interface ChipModalHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
/** Optional leading icon. Pass `null`/omit for a title-only header. */
icon?: React.ComponentType<{ className?: string }> | null
/**
* Optional leading icon. Pass `null`/omit for a title-only header.
*
* A component is drawn in the header's own icon colour; pass a rendered
* element instead when the mark carries its own chrome a brand tile owns
* its fill and contrast, and tinting it grey would be wrong.
*/
icon?: React.ComponentType<{ className?: string }> | React.ReactElement | null
/** Invoked when the trailing close button is activated. Always rendered. */
onClose: () => void
/**
@@ -201,7 +207,11 @@ const ChipModalHeader = React.forwardRef<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'>
{Icon ? <Icon className={chipContentIconClass} /> : null}
{React.isValidElement(Icon) ? (
Icon
) : Icon ? (
<Icon className={chipContentIconClass} />
) : null}
<span className={chipContentLabelClass}>{children}</span>
</div>
<Button