diff --git a/.gitignore b/.gitignore index 13dcca1b..111787bd 100644 --- a/.gitignore +++ b/.gitignore @@ -181,3 +181,6 @@ log.txt # Testing iteration temp files tmp/ + +# Coding agent artifacts +.agent/ diff --git a/apps/agent/.env.example b/apps/agent/.env.example index fc776464..2c35a1f3 100644 --- a/apps/agent/.env.example +++ b/apps/agent/.env.example @@ -11,6 +11,7 @@ BROWSEROS_BINARY=/Applications/BrowserOS.app/Contents/MacOS/BrowserOS VITE_PUBLIC_POSTHOG_KEY= VITE_PUBLIC_POSTHOG_HOST= VITE_PUBLIC_SENTRY_DSN= +VITE_PUBLIC_INTERCOM_APP_ID= # BrowserOS API URL VITE_PUBLIC_BROWSEROS_API=https://api.browseros.com diff --git a/apps/agent/entrypoints/app/main.tsx b/apps/agent/entrypoints/app/main.tsx index 8b6e6cef..d2eab2a0 100644 --- a/apps/agent/entrypoints/app/main.tsx +++ b/apps/agent/entrypoints/app/main.tsx @@ -6,6 +6,7 @@ import { Toaster } from '@/components/ui/sonner' import { AnalyticsProvider } from '@/lib/analytics/AnalyticsProvider.tsx' import { AuthProvider } from '@/lib/auth/AuthProvider' import { QueryProvider } from '@/lib/graphql/QueryProvider' +import { IntercomProvider } from '@/lib/intercom/IntercomProvider' import { sentryRootErrorHandler } from '@/lib/sentry/sentryRootErrorHandler.ts' import { App } from './App' @@ -17,10 +18,12 @@ if ($root) { - - - - + + + + + + diff --git a/apps/agent/entrypoints/intercom.sandbox/index.html b/apps/agent/entrypoints/intercom.sandbox/index.html new file mode 100644 index 00000000..bd724d92 --- /dev/null +++ b/apps/agent/entrypoints/intercom.sandbox/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/apps/agent/entrypoints/intercom.sandbox/main.ts b/apps/agent/entrypoints/intercom.sandbox/main.ts new file mode 100644 index 00000000..b63266b7 --- /dev/null +++ b/apps/agent/entrypoints/intercom.sandbox/main.ts @@ -0,0 +1,101 @@ +import type { IntercomParentMessage } from '../../lib/intercom/intercom' + +type IntercomFn = ((...args: unknown[]) => void) & { q?: unknown[][] } + +declare global { + interface Window { + Intercom: IntercomFn + intercomSettings: Record + } +} + +function sendToParent(message: { type: string }) { + parent.postMessage(message, '*') +} + +function initIntercomStub() { + const i = ((...args: unknown[]) => { + i.c(args) + }) as ((...args: unknown[]) => void) & { + q: unknown[][] + c: (args: unknown[]) => void + } + i.q = [] + i.c = (args: unknown[]) => { + i.q.push(args) + } + window.Intercom = i as IntercomFn +} + +function loadIntercomScript(appId: string): Promise { + return new Promise((resolve, reject) => { + const script = document.createElement('script') + script.src = `https://widget.intercom.io/widget/${appId}` + script.async = true + script.onload = () => resolve() + script.onerror = () => reject(new Error('Failed to load Intercom script')) + document.head.appendChild(script) + }) +} + +let booted = false + +async function handleBoot(appId: string) { + if (booted) return + booted = true + + initIntercomStub() + await loadIntercomScript(appId) + + window.Intercom('boot', { + app_id: appId, + vertical_padding: 0, + horizontal_padding: 0, + }) + + window.Intercom('onShow', () => { + sendToParent({ type: 'intercom:messenger-opened' }) + }) + + window.Intercom('onHide', () => { + sendToParent({ type: 'intercom:messenger-closed' }) + }) + + sendToParent({ type: 'intercom:ready' }) +} + +function handleUpdate(userId: string, email: string, name: string) { + if (!window.Intercom) return + window.Intercom('update', { + user_id: userId, + email, + name, + }) +} + +function handleShutdown() { + if (!window.Intercom) return + window.Intercom('shutdown') +} + +window.addEventListener('message', (event: MessageEvent) => { + const data = event.data as IntercomParentMessage + if ( + !data || + typeof data.type !== 'string' || + !data.type.startsWith('intercom:') + ) + return + + switch (data.type) { + case 'intercom:boot': + handleBoot(data.appId) + break + case 'intercom:update': + handleUpdate(data.userId, data.email, data.name) + break + case 'intercom:shutdown': + handleShutdown() + break + } +}) diff --git a/apps/agent/lib/env.ts b/apps/agent/lib/env.ts index 5596f877..309ecbe3 100644 --- a/apps/agent/lib/env.ts +++ b/apps/agent/lib/env.ts @@ -5,6 +5,7 @@ const EnvSchema = z.object({ VITE_PUBLIC_POSTHOG_KEY: z.string().optional(), VITE_PUBLIC_POSTHOG_HOST: z.string().optional(), VITE_PUBLIC_SENTRY_DSN: z.string().optional(), + VITE_PUBLIC_INTERCOM_APP_ID: z.string().optional(), VITE_PUBLIC_BROWSEROS_API: z.string().optional(), PROD: z.boolean(), }) diff --git a/apps/agent/lib/intercom/IntercomProvider.tsx b/apps/agent/lib/intercom/IntercomProvider.tsx new file mode 100644 index 00000000..177a866d --- /dev/null +++ b/apps/agent/lib/intercom/IntercomProvider.tsx @@ -0,0 +1,119 @@ +import type { FC, PropsWithChildren } from 'react' +import { useEffect, useRef, useState } from 'react' +import { useSessionInfo } from '@/lib/auth/sessionStorage' +import { env } from '@/lib/env' +import { sentry } from '@/lib/sentry/sentry' +import { + INTERCOM_LAUNCHER_HEIGHT, + INTERCOM_LAUNCHER_WIDTH, + INTERCOM_MESSENGER_HEIGHT, + INTERCOM_MESSENGER_WIDTH, + isIntercomSandboxEvent, +} from './intercom' + +export const IntercomProvider: FC = ({ children }) => { + const appId = env.VITE_PUBLIC_INTERCOM_APP_ID + if (!appId) return <>{children} + + return {children} +} + +const IntercomProviderInner: FC> = ({ + appId, + children, +}) => { + const iframeRef = useRef(null) + const iframeWindowRef = useRef(null) + const [isMessengerOpen, setIsMessengerOpen] = useState(false) + const { sessionInfo, isLoading } = useSessionInfo() + + const handleIframeLoad = () => { + iframeWindowRef.current = iframeRef.current?.contentWindow ?? null + try { + iframeWindowRef.current?.postMessage( + { type: 'intercom:boot', appId }, + '*', + ) + } catch (error) { + sentry.captureException(error, { + extra: { message: 'Failed to send boot command to Intercom sandbox' }, + }) + } + } + + useEffect(() => { + const handler = (event: MessageEvent) => { + if (!isIntercomSandboxEvent(event.data)) return + + switch (event.data.type) { + case 'intercom:ready': + break + case 'intercom:messenger-opened': + setIsMessengerOpen(true) + break + case 'intercom:messenger-closed': + setIsMessengerOpen(false) + break + } + } + + window.addEventListener('message', handler) + return () => window.removeEventListener('message', handler) + }, []) + + useEffect(() => { + if (isLoading) return + + try { + if (sessionInfo.user) { + iframeWindowRef.current?.postMessage( + { + type: 'intercom:update', + userId: sessionInfo.user.id, + email: sessionInfo.user.email, + name: sessionInfo.user.name, + }, + '*', + ) + } else { + iframeWindowRef.current?.postMessage({ type: 'intercom:shutdown' }, '*') + } + } catch (error) { + sentry.captureException(error, { + extra: { message: 'Failed to sync identity with Intercom sandbox' }, + }) + } + }, [sessionInfo.user, isLoading]) + + const width = isMessengerOpen + ? INTERCOM_MESSENGER_WIDTH + : INTERCOM_LAUNCHER_WIDTH + const height = isMessengerOpen + ? INTERCOM_MESSENGER_HEIGHT + : INTERCOM_LAUNCHER_HEIGHT + + return ( + <> + {children} +