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