diff --git a/bun.lock b/bun.lock index e36e399..0ce85dd 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,6 @@ "@fontsource-variable/manrope": "^5.2.8", "@fontsource-variable/roboto-mono": "^5.2.9", "@mixedbread/sdk": "^0.56.0", - "@paper-design/shaders-react": "^0.0.76", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-presence": "^1.1.5", @@ -373,10 +372,6 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.51.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Q14+fOGb9T28nWF/0EUsYqERiRA7cl1oy4TJrGmLaqhm+aO2cV+JttboHI3CbdeMCAyDI1+NoSlrM7Melhp/cw=="], - "@paper-design/shaders": ["@paper-design/shaders@0.0.76", "", {}, "sha512-AcNDY4J66YQHUfQYFInkCP7M9VOje0od7wLpOR7LtCmc532opJy6ll+h1W9zBovz8tt9U7OADUmJ/qKEXyOX/A=="], - - "@paper-design/shaders-react": ["@paper-design/shaders-react@0.0.76", "", { "dependencies": { "@paper-design/shaders": "0.0.76" }, "peerDependencies": { "@types/react": "^18 || ^19", "react": "^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-uPJWrYRf6cJdO2H+fuXlahaqz0QjYglNAyUTaRfIInpzCa/d6guxBIK003soAZQFuQ035yg9FhtfFzKNWm+a5A=="], - "@pnpm/tabtab": ["@pnpm/tabtab@0.5.4", "", { "dependencies": { "debug": "^4.3.1", "enquirer": "^2.3.6", "minimist": "^1.2.5", "untildify": "^4.0.0" } }, "sha512-bWLDlHsBlgKY/05wDN/V3ETcn5G2SV/SiA2ZmNvKGGlmVX4G5li7GRDhHcgYvHJHyJ8TUStqg2xtHmCs0UbAbg=="], "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], diff --git a/package.json b/package.json index 73aeb83..ec82a7d 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ "@fontsource-variable/manrope": "^5.2.8", "@fontsource-variable/roboto-mono": "^5.2.9", "@mixedbread/sdk": "^0.56.0", - "@paper-design/shaders-react": "^0.0.76", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-presence": "^1.1.5", diff --git a/src/components/AgentChatWidget.tsx b/src/components/AgentChatWidget.tsx index 8d19d38..845f4b1 100644 --- a/src/components/AgentChatWidget.tsx +++ b/src/components/AgentChatWidget.tsx @@ -1,297 +1,37 @@ -import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react"; -import { useRouterState } from "@tanstack/react-router"; -import { GridIcon } from "@/components/GridIcon"; -import { AQUA_PERIWINKLE } from "@/lib/color-profiles"; - -// Ported from superwall.com's AgentChatWidget.astro: an Intercom-style support -// agent fixed bottom-right. The chat itself is the embedded-agent app -// (`?embed=home`) in an iframe; this component owns the launcher + panel chrome, -// the postMessage bridge, and open/last-chat persistence. Unlike the marketing -// site (an MPA that recreates the widget per navigation), the docs are a SPA — -// the widget mounts once and simply re-pushes page-context on route change. +import { useEffect } from "react"; + +// The Intercom-style support agent, fixed bottom-right (mounted by __root). +// The chrome (dither launcher, teaser, panel) and the postMessage bridge ship +// as the hosted widget script served by the embedded-agent worker — the same +// embed superwall.com uses — so the widget deploys with the agent and can +// never drift from its bridge protocol. This component used to own a ported +// copy of all that chrome; now it just injects the script on the client. // -// Bridge protocol (embedded-agent src/client/dashboardBridge.ts): -// agent → parent ("superwall-agent"): ready | close | chat-changed -// parent → agent ("superwall-dashboard"): theme | page-context - -// Lazy so @paper-design/shaders-react (WebGL) never touches the SSR/prerender -// path — the launcher shows its flat profile fill until the shader hydrates. -const AgentLauncherDither = lazy(() => import("@/components/AgentLauncherDither")); - -const AGENT_ORIGIN = "https://embedded-agent.staffbar.workers.dev"; -const OPEN_KEY = "agentWidget.open"; -const CHAT_KEY = "agentWidget.chatId"; -const TEASER_KEY = "agentWidget.teaserDismissed"; -/** Fired by the search dialog's "Ask AI" button to open the widget. */ +// The script auto-boots the floating presentation: follows the docs theme +// live (.dark/.light on ), re-pushes page context on SPA navigations +// (with each page's same-origin Markdown twin, e.g. /docs/ios → /docs/ios.md), +// persists open/last-chat state in the same agentWidget.* localStorage keys +// as before, and listens for OPEN_AGENT_EVENT to open with a seeded query — +// which the agent pre-fills and auto-sends. data-z-index pins the original +// z-40 stacking. All use cases and config: +// https://github.com/superwall/embedded-agent#embedding-the-agent + +const WIDGET_SRC = "https://embedded-agent.staffbar.workers.dev/widget.js"; +const SCRIPT_ID = "superwall-agent-widget-script"; + +/** Fired by the search dialog's "Ask AI" button to open the widget (detail: {query}). */ export const OPEN_AGENT_EVENT = "superwall:open-agent"; -const TEASER_MESSAGES = [ - "How can we help?", - "Ask us anything.", - "Questions? Ask away.", - "Need a hand?", - "Stuck on something?", - "What are you building?", - "Curious how Superwall works?", - "Have a pricing question?", - "Talk to us.", - "We're here if you need us.", -]; - -const read = (key: string) => { - try { - return localStorage.getItem(key); - } catch { - return null; - } -}; -const write = (key: string, value: string | null) => { - try { - if (value === null) localStorage.removeItem(key); - else localStorage.setItem(key, value); - } catch { - /* storage may be unavailable */ - } -}; - -const resolvedTheme = () => - document.documentElement.classList.contains("dark") ? "dark" : "light"; - -const MARKDOWN_LIMIT = 40_000; - export function AgentChatWidget() { - const [ready, setReady] = useState(false); // client-mounted - const [open, setOpenState] = useState(false); - const [iframeMounted, setIframeMounted] = useState(false); - const [iframeSrc, setIframeSrc] = useState(null); - const [teaser, setTeaser] = useState(null); - - const iframeRef = useRef(null); - const chatIdRef = useRef(null); - const pageMarkdown = useRef | null>(null); - - const pathname = useRouterState({ select: (s) => s.location.pathname }); - - const postToAgent = useCallback((message: object) => { - iframeRef.current?.contentWindow?.postMessage(message, AGENT_ORIGIN); - }, []); - - const postTheme = useCallback(() => { - postToAgent({ - source: "superwall-dashboard", - type: "theme", - payload: { theme: resolvedTheme() }, - }); - }, [postToAgent]); - - // Every docs page has a same-origin Markdown twin (`/docs/ios` → - // `/docs/ios.md`). Fetch it once per page and hand it to the agent so it can - // read what the user is looking at; a missing twin just omits the field. - const fetchPageMarkdown = useCallback(() => { - pageMarkdown.current ??= (async () => { - const clean = window.location.pathname.replace(/\/+$/, ""); - const response = await fetch(clean === "" ? "/index.md" : `${clean}.md`); - if (!response.ok) return null; - return (await response.text()).slice(0, MARKDOWN_LIMIT); - })().catch(() => null); - return pageMarkdown.current; - }, []); - - const postPageContext = useCallback(async () => { - const markdown = await fetchPageMarkdown(); - postToAgent({ - source: "superwall-dashboard", - type: "page-context", - payload: { - pathname: window.location.pathname, - query: window.location.search, - title: document.title, - ...(markdown ? { markdown } : {}), - }, - }); - }, [fetchPageMarkdown, postToAgent]); - - const mountIframe = useCallback((query?: string | null) => { - setIframeMounted((already) => { - if (already) return already; - const src = new URL("/", AGENT_ORIGIN); - src.searchParams.set("embed", "home"); - src.searchParams.set("theme", resolvedTheme()); - const chatId = read(CHAT_KEY); - if (chatId) src.searchParams.set("chat", chatId); - // Seed the composer from the docs search query (see the embedded-agent - // EMBED_QUERY_PREFILL_TASK). `send=1` asks it to auto-send. - const q = query?.trim(); - if (q) { - src.searchParams.set("query", q); - src.searchParams.set("send", "1"); - } - setIframeSrc(src.href); - return true; - }); - }, []); - - const hideTeaser = useCallback((persist: boolean) => { - setTeaser(null); - if (persist) write(TEASER_KEY, "1"); - }, []); - - const setOpen = useCallback( - (next: boolean, query?: string | null) => { - if (next) { - const q = query?.trim() || null; - // Cold open → seed via the iframe URL. Warm open (iframe already - // mounted, so the URL can't change) → inject via the `prompt` bridge - // message the embedded-agent handles. - if (q && iframeRef.current) { - postToAgent({ - source: "superwall-dashboard", - type: "prompt", - payload: { content: q, autoSend: true }, - }); - } else { - mountIframe(q); - } - hideTeaser(true); - } - setOpenState(next); - write(OPEN_KEY, next ? "1" : null); - }, - [mountIframe, hideTeaser, postToAgent], - ); - - // Client bootstrap: restore open state, wire the "Ask AI" opener, and (if not - // dismissed / already open) greet with a teaser after a beat. useEffect(() => { - setReady(true); - if (read(OPEN_KEY) === "1") setOpen(true); - - const onOpenAgent = (event: Event) => - setOpen(true, (event as CustomEvent<{ query?: string }>).detail?.query); - window.addEventListener(OPEN_AGENT_EVENT, onOpenAgent); - - let teaserTimer: number | undefined; - if (read(OPEN_KEY) !== "1" && read(TEASER_KEY) !== "1") { - teaserTimer = window.setTimeout(() => { - setOpenState((isOpen) => { - if (!isOpen) { - setTeaser(TEASER_MESSAGES[Math.floor(Math.random() * TEASER_MESSAGES.length)]!); - } - return isOpen; - }); - }, 1000); - } - return () => { - window.removeEventListener(OPEN_AGENT_EVENT, onOpenAgent); - if (teaserTimer) window.clearTimeout(teaserTimer); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps + if (document.getElementById(SCRIPT_ID)) return; + const script = document.createElement("script"); + script.id = SCRIPT_ID; + script.src = WIDGET_SRC; + script.dataset.zIndex = "40"; + document.body.appendChild(script); + // No teardown: the root layout mounts this once for the app's lifetime. }, []); - // Bridge: agent → parent messages. - useEffect(() => { - const onMessage = (event: MessageEvent) => { - if (event.origin !== AGENT_ORIGIN) return; - const data = event.data as - | { source?: string; type?: string; payload?: { chatId?: string | null } } - | undefined; - if (!data || data.source !== "superwall-agent") return; - if (data.type === "ready") { - postTheme(); - void postPageContext(); - } - if (data.type === "close") setOpen(false); - if (data.type === "chat-changed") { - chatIdRef.current = data.payload?.chatId ?? null; - write(CHAT_KEY, chatIdRef.current); - } - }; - window.addEventListener("message", onMessage); - return () => window.removeEventListener("message", onMessage); - }, [postTheme, postPageContext, setOpen]); - - // Follow live theme toggles (ThemeToggle mutates the class). - useEffect(() => { - if (!iframeMounted) return; - const observer = new MutationObserver(() => postTheme()); - observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); - return () => observer.disconnect(); - }, [iframeMounted, postTheme]); - - // Re-push page-context on client navigation (fresh markdown twin per page). - useEffect(() => { - if (!iframeMounted) return; - pageMarkdown.current = null; - void postPageContext(); - }, [pathname, iframeMounted, postPageContext]); - - if (!ready) return null; - - const profile = AQUA_PERIWINKLE; - - return ( -
-
- {iframeMounted && iframeSrc ? ( -