mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-21 21:15:56 +08:00
This reverts commit b7a5f22abd.
This commit is contained in:
+1
-17
@@ -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)} />
|
||||
)
|
||||
}
|
||||
+1
@@ -258,6 +258,7 @@ export function useAvailableResources(
|
||||
id: integration.blockType,
|
||||
name: integration.name,
|
||||
iconComponent: integration.icon,
|
||||
bgColor: integration.bgColor,
|
||||
})),
|
||||
},
|
||||
{
|
||||
|
||||
+15
-25
@@ -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)
|
||||
|
||||
+18
-8
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
+6
-25
@@ -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>
|
||||
)
|
||||
|
||||
+14
-5
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
+22
-2
@@ -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>
|
||||
|
||||
-44
@@ -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'
|
||||
+21
-2
@@ -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
-5
@@ -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
|
||||
|
||||
+262
@@ -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
|
||||
}
|
||||
|
||||
+5
@@ -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'
|
||||
|
||||
+77
@@ -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])
|
||||
}
|
||||
+365
@@ -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,
|
||||
}
|
||||
}
|
||||
+137
@@ -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
|
||||
}
|
||||
+355
@@ -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,
|
||||
}
|
||||
}
|
||||
+232
@@ -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,
|
||||
}
|
||||
}
|
||||
+11
@@ -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
|
||||
}
|
||||
+79
@@ -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
|
||||
*/
|
||||
|
||||
+11
-5
@@ -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)
|
||||
|
||||
+11
-2
@@ -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>
|
||||
|
||||
+11
-5
@@ -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)
|
||||
|
||||
+1
-2
@@ -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'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+6
-3
@@ -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',
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user