Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
318 changes: 29 additions & 289 deletions src/components/AgentChatWidget.tsx
Original file line number Diff line number Diff line change
@@ -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 <html>), 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<string | null>(null);
const [teaser, setTeaser] = useState<string | null>(null);

const iframeRef = useRef<HTMLIFrameElement | null>(null);
const chatIdRef = useRef<string | null>(null);
const pageMarkdown = useRef<Promise<string | null> | 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 <html> 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 (
<div
data-agent-widget
data-open={open ? "true" : "false"}
className="pointer-events-none fixed right-4 bottom-4 z-40 flex flex-col items-end gap-3"
>
<div
data-agent-panel
className="h-[min(45rem,calc(100dvh-8rem))] w-[min(25rem,calc(100vw-2rem))] border border-fd-border bg-fd-background"
>
{iframeMounted && iframeSrc ? (
<iframe
ref={iframeRef}
src={iframeSrc}
title="Superwall assistant"
allow="clipboard-write"
/>
) : null}
</div>

{teaser ? (
<div
data-agent-teaser
data-visible=""
className="flex items-center gap-3 bg-fd-foreground py-3 pr-3 pl-4"
>
<button
type="button"
onClick={() => setOpen(true)}
className="text-sm font-medium text-fd-background"
>
{teaser}
</button>
<button
type="button"
onClick={() => hideTeaser(true)}
aria-label="Dismiss"
className="inline-flex size-6 items-center justify-center text-fd-background/40 transition-colors duration-150 hover:text-fd-background"
>
<GridIcon name="x" className="size-3" />
</button>
</div>
) : null}

<button
type="button"
data-agent-launcher
aria-label="Chat with Superwall"
aria-expanded={open}
onClick={() => setOpen(!open)}
style={{ backgroundColor: profile.background, color: profile.text }}
className="pointer-events-auto relative inline-flex size-12 shrink-0 items-center justify-center overflow-hidden p-0 leading-none transition-transform duration-150 ease-out active:scale-[0.98]"
>
<Suspense fallback={null}>
<AgentLauncherDither />
</Suspense>
<span
className="absolute inset-0"
style={{ backgroundColor: `color-mix(in srgb, ${profile.text} 15%, transparent)` }}
aria-hidden="true"
/>
<GridIcon name={open ? "x" : "message"} className="relative z-10 size-4" />
</button>
</div>
);
return null;
}
Loading
Loading