chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,33 @@
'use client'
import { ChipLink } from '@sim/emcn'
import { ArrowLeft } from 'lucide-react'
interface IntegrationBlockDetailFallbackProps {
workspaceId: string
}
/**
* Suspense fallback for the integration detail page — the back-link chrome
* shown while {@link IntegrationBlockDetail} hydrates.
*
* This MUST be a client component. The lucide `ArrowLeft` passed as `ChipLink`'s
* `leftIcon` is a function, and functions cannot cross the server→client
* boundary as props. Rendering the fallback from the server `page.tsx` directly
* threw a React Server Components error ("Functions cannot be passed directly to
* Client Components") that surfaced as the integrations error boundary. Keeping
* the icon inside a client component avoids the boundary crossing entirely.
*/
export function IntegrationBlockDetailFallback({
workspaceId,
}: IntegrationBlockDetailFallbackProps) {
return (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='flex flex-shrink-0 items-center bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
<ChipLink href={`/workspace/${workspaceId}/integrations`} leftIcon={ArrowLeft}>
Integrations
</ChipLink>
</div>
</div>
)
}
@@ -0,0 +1,367 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Chip, ChipDropdown, ChipLink, cn } from '@sim/emcn'
import { ArrowLeft, ArrowRight, Plus } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useQueryState } from 'nuqs'
import {
blockTypeToIconMap,
type Integration,
resolveOAuthServiceForIntegration,
} from '@/lib/integrations'
import { getServiceConfigByProviderId } from '@/lib/oauth'
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
import { IntegrationSkillsSection } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section'
import { connectParam } from '@/app/workspace/[workspaceId]/integrations/[block]/search-params'
import { ConnectServiceAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal'
import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section'
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route'
import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration'
import { getBlock } from '@/blocks'
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
import { getTileIconColorClass } from '@/blocks/icon-color'
import { storeCuratedPrompt } from '@/blocks/integration-matcher'
import {
getSuggestedSkillsForBlock,
getTemplatesForBlock,
type ScopedBlockTemplate,
} from '@/blocks/registry'
import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context'
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
/** Maximum number of overlapping icon tiles rendered per template row. */
const TEMPLATE_CLUSTER_MAX = 3 as const
/**
* Z-index per cluster position so the primary tile reads on top and trailing
* tiles cascade behind it.
*/
const TEMPLATE_TILE_Z = ['z-30', 'z-20', 'z-10'] as const
interface IntegrationBlockDetailProps {
integration: Integration
workspaceId: string
}
export function IntegrationBlockDetail({ integration, workspaceId }: IntegrationBlockDetailProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null)
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)
const [oauthOpen, setOAuthOpen] = useState(false)
const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({
workspaceId,
enabled: Boolean(workspaceId),
})
useScrollRestoration(scrollContainerRef, { ready: !credentialsLoading })
const connectedCredentials = useMemo(() => {
if (!oauthService) return []
return credentials.filter(
(c) =>
(c.type === 'oauth' || c.type === 'service_account') &&
c.providerId &&
getServiceConfigByProviderId(c.providerId)?.providerId === oauthService.providerId
)
}, [credentials, oauthService])
const [serviceAccountOpen, setServiceAccountOpen] = useState(false)
const isSlackBot = oauthService?.serviceAccountProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID
const blockOverlayVersion = useCustomBlockOverlayVersion()
// Custom Slack bots ride the slack_v2 preview flag: the setup surface stays
// hidden until that block is revealed for this viewer.
const slackBotPreviewHidden = useMemo(() => {
if (!isSlackBot) return false
const v2 = getBlock('slack_v2')
return !v2 || isHiddenUnder(overlayVisibility(), v2)
}, [isSlackBot, blockOverlayVersion])
const hasServiceAccount =
Boolean(oauthService?.serviceAccountProviderId) && !slackBotPreviewHidden
const hasHandledConnectQueryRef = useRef(false)
useEffect(() => {
if (hasHandledConnectQueryRef.current) return
if (!connectMode) return
let handled = false
if (connectMode === CONNECT_MODE.oauth && oauthService) {
setOAuthOpen(true)
handled = true
} else if (connectMode === CONNECT_MODE.serviceAccount && hasServiceAccount) {
setServiceAccountOpen(true)
handled = true
}
if (!handled) return
hasHandledConnectQueryRef.current = true
void setConnectMode(null, { history: 'replace', scroll: false })
}, [connectMode, oauthService, hasServiceAccount, setConnectMode])
const connectOptions = oauthService
? [
{
value: CONNECT_MODE.oauth,
label: 'Connect with OAuth',
icon: oauthService.serviceIcon,
},
{
value: CONNECT_MODE.serviceAccount,
label: isSlackBot ? 'Set up a custom bot' : 'Add service account',
icon: oauthService.serviceIcon,
},
]
: []
const handleSelectConnectOption = (value: string) => {
if (value === CONNECT_MODE.oauth) setOAuthOpen(true)
else if (value === CONNECT_MODE.serviceAccount) setServiceAccountOpen(true)
}
const handleAddInChat = () => {
storeCuratedPrompt(`Explore ${integration.name}. What can I do?`)
router.push(`/workspace/${workspaceId}/home`)
}
return (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='flex flex-shrink-0 items-center bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
<ChipLink href={`/workspace/${workspaceId}/integrations`} leftIcon={ArrowLeft}>
Integrations
</ChipLink>
<div className='ml-auto flex items-center'>
{oauthService ? (
hasServiceAccount ? (
<ChipDropdown
variant='primary'
leftIcon={Plus}
placeholder='Add to Sim'
showSelectedCheck={false}
options={connectOptions}
onChange={handleSelectConnectOption}
matchTriggerWidth={false}
/>
) : (
<Chip variant='primary' leftIcon={Plus} onClick={() => setOAuthOpen(true)}>
Add to Sim
</Chip>
)
) : (
<Chip variant='primary' leftIcon={Plus} onClick={handleAddInChat}>
Add to Sim
</Chip>
)}
</div>
</div>
{oauthService && (
<ConnectOAuthModal
mode='connect'
origin='integrations'
open={oauthOpen}
onOpenChange={setOAuthOpen}
workspaceId={workspaceId}
providerId={oauthService.providerId}
requiredScopes={oauthService.requiredScopes}
serviceName={oauthService.serviceName}
serviceIcon={oauthService.serviceIcon}
/>
)}
{hasServiceAccount && oauthService?.serviceAccountProviderId && (
<ConnectServiceAccountModal
open={serviceAccountOpen}
onOpenChange={setServiceAccountOpen}
workspaceId={workspaceId}
serviceAccountProviderId={oauthService.serviceAccountProviderId}
serviceName={oauthService.serviceName}
serviceIcon={oauthService.serviceIcon}
/>
)}
<div
ref={scrollContainerRef}
className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'
>
<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(
'flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)]',
getTileIconColorClass(integration.bgColor)
)}
style={{ background: integration.bgColor }}
>
{integration.name.charAt(0)}
</div>
)}
<div className='flex flex-col gap-1'>
<h1 className='font-medium text-[var(--text-body)] text-lg'>{integration.name}</h1>
<p className='text-[var(--text-muted)] text-md'>{integration.description}</p>
</div>
</div>
{connectedCredentials.length > 0 && (
<IntegrationSection label='Connected'>
{connectedCredentials.map((credential) => (
<Link
key={credential.id}
href={`/workspace/${workspaceId}/integrations/connected/${credential.id}`}
className='flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
>
{Icon && <IntegrationTile blockType={integration.type} icon={Icon} />}
<div className='flex min-w-0 flex-1 flex-col'>
<span className='truncate text-[14px] text-[var(--text-body)]'>
{credential.displayName}
</span>
<span className='truncate text-[12px] text-[var(--text-muted)]'>
{credential.description || oauthService?.serviceName}
</span>
</div>
<ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' />
</Link>
))}
</IntegrationSection>
)}
{suggestedSkills.length > 0 && (
<IntegrationSkillsSection
skills={suggestedSkills}
workspaceId={workspaceId}
integrationType={integration.type}
/>
)}
{matchingTemplates.length > 0 && (
<TemplatesSection
integration={integration}
templates={matchingTemplates}
workspaceId={workspaceId}
/>
)}
</div>
</div>
</div>
)
}
interface TemplatesSectionProps {
integration: Integration
templates: readonly ScopedBlockTemplate[]
workspaceId: string
}
function TemplatesSection({ integration, templates, workspaceId }: TemplatesSectionProps) {
const router = useRouter()
const handleSelect = (prompt: string) => {
storeCuratedPrompt(prompt)
router.push(`/workspace/${workspaceId}/home`)
}
return (
<section className='flex flex-col'>
<span className='pl-0.5 text-[var(--text-muted)] text-small'>Templates</span>
<div className='mt-[9px] mb-3 h-px bg-[var(--border)]' />
<div className='-mx-2 flex flex-col gap-y-0.5'>
{templates.map((template) => {
const blockTypes = [integration.type, ...template.otherBlockTypes].slice(
0,
TEMPLATE_CLUSTER_MAX
)
return (
<TemplateRow
key={template.title}
blockTypes={blockTypes}
title={template.title}
prompt={template.prompt}
onSelect={handleSelect}
/>
)
})}
</div>
</section>
)
}
interface TemplateRowProps {
blockTypes: string[]
title: string
prompt: string
onSelect: (prompt: string) => void
}
/**
* Template row that mirrors `IntegrationItem` from the integrations index
* byte-for-byte (icon cluster · title · description · trailing `ArrowRight`).
* Renders as a `<button>` because click seeds the home page chat with `prompt`
* and navigates to the workspace home, matching the `ShowcaseWithExplore` flow.
*/
function TemplateRow({ blockTypes, title, prompt, onSelect }: TemplateRowProps) {
return (
<button
type='button'
onClick={() => onSelect(prompt)}
className='group flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
>
<TemplateIcons blockTypes={blockTypes} />
<div className='flex min-w-0 flex-1 flex-col'>
<span className='truncate text-[14px] text-[var(--text-body)]'>{title}</span>
<span className='truncate text-[12px] text-[var(--text-muted)]'>{prompt}</span>
</div>
<ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' />
</button>
)
}
interface TemplateIconsProps {
blockTypes: string[]
}
/**
* Horizontal overlapping icon cluster. Primary integration (idx === 0) sits
* left and on top, rendered identically to a bare `IntegrationTile` so it
* matches `IntegrationItem` outside the templates list with no halo. Trailing
* tiles cascade behind with negative margin and carry a non-layout-affecting
* outline whose color tracks the row background exactly — `--bg` at rest and
* `--surface-active` on row hover via the parent `group`. The outline is
* visually invisible in both states yet cleanly cuts each silhouette from the
* tile behind it. `outline` is preferred over `ring` here because it has no
* `box-shadow` specificity collisions and lets us transition just the
* `outline-color` token rather than the full shadow stack.
*/
function TemplateIcons({ blockTypes }: TemplateIconsProps) {
return (
<span aria-hidden className='flex items-center'>
{blockTypes.map((bt, idx) => {
const ToolIcon = blockTypeToIconMap[bt]
if (!ToolIcon) return null
const z = TEMPLATE_TILE_Z[idx]
if (!z) return null
const isTrailing = idx > 0
return (
<span
key={bt}
className={cn(
'[&:not(:first-child)]:-ml-2 relative rounded-xl first:ml-0',
z,
isTrailing &&
'outline outline-2 outline-[var(--bg)] transition-[outline-color] duration-150 group-hover:outline-[var(--surface-active)]'
)}
>
<IntegrationTile blockType={bt} icon={ToolIcon} />
</span>
)
})}
</span>
)
}
@@ -0,0 +1,110 @@
'use client'
import { useMemo, useRef, useState } from 'react'
import { Chip, toast } from '@sim/emcn'
import { Check, Plus } from 'lucide-react'
import { usePostHog } from 'posthog-js/react'
import { captureEvent } from '@/lib/posthog/client'
import { SkillTile } from '@/app/workspace/[workspaceId]/components'
import type { SuggestedSkill } from '@/blocks/types'
import { useCreateSkill, useSkills } from '@/hooks/queries/skills'
interface IntegrationSkillsSectionProps {
skills: readonly SuggestedSkill[]
workspaceId: string
integrationType: string
}
interface SkillRowProps {
skill: SuggestedSkill
added: boolean
pending: boolean
disabled: boolean
onAdd: () => void
}
function SkillRow({ skill, added, pending, disabled, onAdd }: SkillRowProps) {
return (
<div className='flex items-center gap-2.5 rounded-lg p-2'>
<SkillTile />
<div className='flex min-w-0 flex-1 flex-col'>
<span className='truncate text-[14px] text-[var(--text-body)]'>{skill.name}</span>
<span className='truncate text-[12px] text-[var(--text-muted)]'>{skill.description}</span>
</div>
{added ? (
<Chip leftIcon={Check} disabled flush>
Added
</Chip>
) : (
<Chip variant='primary' leftIcon={Plus} onClick={onAdd} disabled={disabled} flush>
{pending ? 'Adding...' : 'Add'}
</Chip>
)}
</div>
)
}
/**
* Curated, research-backed skills for an integration. Each row adds the skill
* to the workspace via the same `useCreateSkill` mutation the Skills page uses;
* `useSkills` is the single source of truth for the "Added" state, so a skill
* removed elsewhere correctly reverts to "Add".
*/
export function IntegrationSkillsSection({
skills,
workspaceId,
integrationType,
}: IntegrationSkillsSectionProps) {
const posthog = usePostHog()
const { data: existingSkills = [], isPending, isPlaceholderData } = useSkills(workspaceId)
const createSkill = useCreateSkill()
const skillsReady = !isPending && !isPlaceholderData
const [pendingNames, setPendingNames] = useState<ReadonlySet<string>>(new Set())
const inFlightRef = useRef<Set<string>>(new Set())
const existingNames = useMemo(() => new Set(existingSkills.map((s) => s.name)), [existingSkills])
const handleAdd = async (skill: SuggestedSkill, position: number) => {
if (inFlightRef.current.has(skill.name)) return
inFlightRef.current.add(skill.name)
setPendingNames((prev) => new Set(prev).add(skill.name))
try {
await createSkill.mutateAsync({ workspaceId, skill })
captureEvent(posthog, 'integration_skill_added', {
workspace_id: workspaceId,
integration_type: integrationType,
skill_name: skill.name,
position,
skill_count: skills.length,
})
} catch {
toast.error(`Failed to add "${skill.name}" — please try again`)
} finally {
inFlightRef.current.delete(skill.name)
setPendingNames((prev) => {
const next = new Set(prev)
next.delete(skill.name)
return next
})
}
}
return (
<section className='flex flex-col'>
<span className='pl-0.5 text-[var(--text-muted)] text-small'>Skills</span>
<div className='mt-[9px] mb-3 h-px bg-[var(--border)]' />
<div className='-mx-2 flex flex-col gap-y-0.5'>
{skills.map((skill, index) => (
<SkillRow
key={skill.name}
skill={skill}
added={skillsReady && existingNames.has(skill.name)}
pending={pendingNames.has(skill.name)}
disabled={pendingNames.has(skill.name) || !skillsReady}
onAdd={() => handleAdd(skill, index)}
/>
))}
</div>
</section>
)
}
@@ -0,0 +1,34 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { INTEGRATIONS } from '@/lib/integrations'
import { IntegrationBlockDetail } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail'
import { IntegrationBlockDetailFallback } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail-fallback'
export async function generateMetadata({
params,
}: {
params: Promise<{ block: string }>
}): Promise<Metadata> {
const { block } = await params
const integration = INTEGRATIONS.find((i) => i.slug === block)
return {
title: integration ? `${integration.name} Integration` : 'Integration',
}
}
export default async function IntegrationBlockPage({
params,
}: {
params: Promise<{ workspaceId: string; block: string }>
}) {
const { workspaceId, block } = await params
const integration = INTEGRATIONS.find((i) => i.slug === block)
if (!integration) notFound()
return (
<Suspense fallback={<IntegrationBlockDetailFallback workspaceId={workspaceId} />}>
<IntegrationBlockDetail integration={integration} workspaceId={workspaceId} />
</Suspense>
)
}
@@ -0,0 +1,17 @@
import { parseAsStringLiteral } from 'nuqs/server'
import {
CONNECT_MODE,
CONNECT_QUERY_PARAM,
} from '@/app/workspace/[workspaceId]/integrations/connect-route'
const CONNECT_MODE_VALUES = [CONNECT_MODE.oauth, CONNECT_MODE.serviceAccount] as const
/**
* Typed parser for the ephemeral `?connect=oauth|service-account` deep-link on
* the integration detail page. The param is read once to pre-open the matching
* connect modal, then stripped from the URL.
*/
export const connectParam = {
key: CONNECT_QUERY_PARAM,
parser: parseAsStringLiteral(CONNECT_MODE_VALUES),
} as const